├── samples ├── diskusage │ ├── .gitignore │ └── index.html ├── test1.lhtml ├── test1 │ ├── duck.jpg │ └── index.html ├── noformsaving │ ├── duck.jpg │ └── index.html ├── exportfile │ └── index.html ├── filesystem │ └── index.html ├── external │ └── index.html └── sizing │ └── index.html ├── changes ├── feature-size.md ├── fix-about-icon.md ├── feature-show-logs.md ├── fix-winlinux-menu.md ├── feature-closedontclose.md ├── feature-devtoolskeyboard.md └── feature-exportfiles.md ├── doc └── icon.png ├── .esdoc.json ├── demos ├── demo.lhtml └── form │ ├── fonts │ └── roboto │ │ ├── Roboto-Bold.eot │ │ ├── Roboto-Bold.ttf │ │ ├── Roboto-Bold.woff │ │ ├── Roboto-Bold.woff2 │ │ ├── Roboto-Light.eot │ │ ├── Roboto-Light.ttf │ │ ├── Roboto-Light.woff │ │ ├── Roboto-Medium.eot │ │ ├── Roboto-Medium.ttf │ │ ├── Roboto-Thin.eot │ │ ├── Roboto-Thin.ttf │ │ ├── Roboto-Thin.woff │ │ ├── Roboto-Thin.woff2 │ │ ├── Roboto-Light.woff2 │ │ ├── Roboto-Medium.woff │ │ ├── Roboto-Medium.woff2 │ │ ├── Roboto-Regular.eot │ │ ├── Roboto-Regular.ttf │ │ ├── Roboto-Regular.woff │ │ └── Roboto-Regular.woff2 │ └── index.html ├── src ├── img │ ├── icon.png │ └── arrow.png ├── default.html ├── lhtml_container.html ├── prefs │ ├── prefs.js │ └── prefs.html ├── rpc.js ├── updates.html ├── locks.js ├── guest │ ├── formsync.js │ └── preload.js ├── chrootfs.js └── main.js ├── dev ├── icons │ ├── blank_dmg.png │ ├── lhtmlbig.png │ ├── lhtmldmg.png │ ├── lhtmldoc.png │ ├── lhtml_wmargins.png │ ├── README.md │ └── mkicns.sh └── publish │ ├── publish.sh │ ├── updatechangelog.sh │ └── combine_changes.sh ├── .gitignore ├── test ├── cases │ ├── oneinput │ │ └── index.html │ └── form │ │ └── index.html ├── runtests.sh ├── checkforchanges.sh ├── test_locks.js └── test_chrootfs.js ├── .travis.yml ├── functest ├── mocks.js ├── e2e.js ├── util.js └── test_e2e.js ├── CONTRIBUTING.md ├── TODO ├── CHANGELOG.md ├── package.json ├── README.md ├── api.md └── LICENSE /samples/diskusage/.gitignore: -------------------------------------------------------------------------------- 1 | theblob-* 2 | -------------------------------------------------------------------------------- /changes/feature-size.md: -------------------------------------------------------------------------------- 1 | Users can allow documents to take more space. -------------------------------------------------------------------------------- /changes/fix-about-icon.md: -------------------------------------------------------------------------------- 1 | Broken image in About window fixed 2 | -------------------------------------------------------------------------------- /doc/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/doc/icon.png -------------------------------------------------------------------------------- /.esdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "./guest", 3 | "destination": "./doc" 4 | } -------------------------------------------------------------------------------- /demos/demo.lhtml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/demo.lhtml -------------------------------------------------------------------------------- /src/img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/src/img/icon.png -------------------------------------------------------------------------------- /src/img/arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/src/img/arrow.png -------------------------------------------------------------------------------- /samples/test1.lhtml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/samples/test1.lhtml -------------------------------------------------------------------------------- /dev/icons/blank_dmg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/dev/icons/blank_dmg.png -------------------------------------------------------------------------------- /dev/icons/lhtmlbig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/dev/icons/lhtmlbig.png -------------------------------------------------------------------------------- /dev/icons/lhtmldmg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/dev/icons/lhtmldmg.png -------------------------------------------------------------------------------- /dev/icons/lhtmldoc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/dev/icons/lhtmldoc.png -------------------------------------------------------------------------------- /samples/test1/duck.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/samples/test1/duck.jpg -------------------------------------------------------------------------------- /changes/feature-show-logs.md: -------------------------------------------------------------------------------- 1 | Add Help > Show Logs menu item for easily getting to the log file 2 | -------------------------------------------------------------------------------- /changes/fix-winlinux-menu.md: -------------------------------------------------------------------------------- 1 | Add missing menu items to Windows/Linux: About, Check for updates... 2 | -------------------------------------------------------------------------------- /dev/icons/lhtml_wmargins.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/dev/icons/lhtml_wmargins.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | samples/filesystem/thedate.txt 4 | .DS_Store 5 | _CHANGELOG.md 6 | -------------------------------------------------------------------------------- /samples/noformsaving/duck.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/samples/noformsaving/duck.jpg -------------------------------------------------------------------------------- /changes/feature-closedontclose.md: -------------------------------------------------------------------------------- 1 | Default action when closing an unsaved document is to keep the document open 2 | -------------------------------------------------------------------------------- /changes/feature-devtoolskeyboard.md: -------------------------------------------------------------------------------- 1 | Most keyboard shortcuts now work even when you're focused on the document's devTools 2 | -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Bold.eot -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Bold.ttf -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Bold.woff -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Bold.woff2 -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Light.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Light.eot -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Light.ttf -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Light.woff -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Medium.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Medium.eot -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Medium.ttf -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Thin.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Thin.eot -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Thin.ttf -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Thin.woff -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Thin.woff2 -------------------------------------------------------------------------------- /dev/icons/README.md: -------------------------------------------------------------------------------- 1 | Source files for things like the LHTML icons. 2 | 3 | I use to convert -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Light.woff2 -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Medium.woff -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Medium.woff2 -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Regular.eot -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Regular.ttf -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Regular.woff -------------------------------------------------------------------------------- /demos/form/fonts/roboto/Roboto-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iffy/lhtml/HEAD/demos/form/fonts/roboto/Roboto-Regular.woff2 -------------------------------------------------------------------------------- /changes/feature-exportfiles.md: -------------------------------------------------------------------------------- 1 | Added `saving.exportFile(...)` to allow documents to offer up files to be saved outside of the document. 2 | -------------------------------------------------------------------------------- /test/cases/oneinput/index.html: -------------------------------------------------------------------------------- 1 | 2 | One input 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /dev/publish/publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if [ -z "$GH_TOKEN" ]; then 4 | echo "You must set GH_TOKEN" 5 | exit 1 6 | fi 7 | 8 | build --win --mac --linux --ia32 --x64 --draft -p always 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | os: 2 | - linux 3 | - osx 4 | 5 | language: node_js 6 | 7 | node_js: 8 | - '7' 9 | 10 | script: 11 | - ./test/runtests.sh 12 | - ./test/checkforchanges.sh 13 | 14 | cache: 15 | yarn: true 16 | directories: 17 | - node_modules 18 | -------------------------------------------------------------------------------- /test/runtests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then 4 | export DISPLAY=:99.0 5 | sh -e /etc/init.d/xvfb start 6 | sleep 3 7 | fi 8 | 9 | echo "node $(node --version)" 10 | echo "npm $(npm --version)" 11 | echo "yarn $(yarn --version)" 12 | 13 | rc=0 14 | if ! mocha; then 15 | rc=1 16 | fi 17 | if ! RUN_TESTS="yes" electron . ; then 18 | rc=1 19 | fi 20 | exit $rc 21 | -------------------------------------------------------------------------------- /samples/exportfile/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 18 | 19 | -------------------------------------------------------------------------------- /dev/icons/mkicns.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | SOURCE=$1 4 | DST=$2 5 | 6 | if [ -z "$SOURCE" ] || [ -z "$DST" ]; then 7 | echo "usage: $0 source.png dst.icns" 8 | exit 1 9 | fi 10 | 11 | TMPDIR=$(mktemp -d -t mkicns) 12 | echo "Working in $TMPDIR" 13 | 14 | subdir="${TMPDIR}/whatevs.iconset" 15 | mkdir -p "$subdir" 16 | 17 | for i in 16 32 128 256 512; do 18 | convert -resize $i "$SOURCE" "${subdir}/icon_${i}x${i}.png" 19 | done 20 | 21 | iconutil --convert icns -o "$DST" "$subdir" 22 | -------------------------------------------------------------------------------- /samples/noformsaving/index.html: -------------------------------------------------------------------------------- 1 | 2 | Some test page 3 | 4 | 5 | Test page. 6 | 16 | woooooo 17 | woooooo 18 | 19 |
No ducks here
20 | -------------------------------------------------------------------------------- /samples/test1/index.html: -------------------------------------------------------------------------------- 1 | 2 | Some test page 3 | 4 | 5 | Test page. 6 | got to the endgot to the endgot to the end 15 |
16 | /tmp/duck: 17 | 18 |
19 |
20 | duck.jpg 21 | 22 |
23 | 24 | -------------------------------------------------------------------------------- /functest/mocks.js: -------------------------------------------------------------------------------- 1 | var {dialog} = require('electron'); 2 | 3 | let dialog_path = []; 4 | let message_choice = 0; 5 | 6 | dialog.showOpenDialog = (options, cb) => { 7 | cb(dialog_path); 8 | } 9 | dialog.showSaveDialog = (options, cb) => { 10 | cb(dialog_path); 11 | } 12 | dialog.showMessageBox = (options) => { 13 | return message_choice; 14 | } 15 | 16 | function setDialogAnswer(answer) { 17 | dialog_path = answer; 18 | } 19 | function setMessageBoxAnswer(answer) { 20 | message_choice = answer; 21 | } 22 | 23 | module.exports = {setDialogAnswer, setMessageBoxAnswer}; 24 | -------------------------------------------------------------------------------- /samples/diskusage/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | File Size 5 | 6 | 7 | 8 | 20 | 21 | -------------------------------------------------------------------------------- /test/checkforchanges.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TARGET_BRANCH=${TRAVIS_BRANCH:-master} 4 | 5 | PR_BRANCH=${TRAVIS_PULL_REQUEST_BRANCH:-$1} 6 | if [ -z "$PR_BRANCH" ]; then 7 | echo "Not a pull request, so skipping change snippet check" 8 | exit 0 9 | fi 10 | 11 | git fetch origin "${TARGET_BRANCH}" 12 | ADDED_SNIPPETS=$(git diff --name-only --diff-filter=A "${TARGET_BRANCH}..." -- changes/) 13 | 14 | echo "$ADDED_SNIPPETS" 15 | 16 | if [ -z "$ADDED_SNIPPETS" ]; then 17 | echo "FAILURE: Missing a change snippet." 18 | exit 1 19 | else 20 | echo "OK: At least one change snippet detected." 21 | fi 22 | -------------------------------------------------------------------------------- /samples/filesystem/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FileSystem 5 | 6 | 7 |
8 | 24 | 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 1. Specify changes by creating single line text files in `changes/` The filename should be of the form: 2 | 3 | -.md 4 | 5 | where `` is one of: 6 | 7 | | prefix | what to use it for | 8 | |---|---| 9 | | `break` | **IMPORTANT:** Change breaks backward compatibility | 10 | | `fix` | Change fixes a bug | 11 | | `feature`, `new` | Change adds a new feature | 12 | | `info` | Informational change | 13 | | `doc` | Documentation change | 14 | | `refactor` | Change is a refactor | 15 | 16 | and `` is a unique-ish identifier such as an issue number, your name, a short description of the change, etc... 17 | 18 | 2. The tests ought to pass. 19 | -------------------------------------------------------------------------------- /dev/publish/updatechangelog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # this isn't perfect, but it's close enough 6 | thisdir="$(dirname $0)" 7 | CHANGELOG="${thisdir}/../../CHANGELOG.md" 8 | echo '' > _CHANGELOG.md 9 | 10 | #--------------------------------------------------------------- 11 | # New changes 12 | #--------------------------------------------------------------- 13 | "${thisdir}/combine_changes.sh" | tee -a _CHANGELOG.md 14 | 15 | #--------------------------------------------------------------- 16 | # Old changelog 17 | #--------------------------------------------------------------- 18 | tail -n +2 "${CHANGELOG}" >> _CHANGELOG.md 19 | mv _CHANGELOG.md "${CHANGELOG}" 20 | 21 | #--------------------------------------------------------------- 22 | # Delete old snippets 23 | #--------------------------------------------------------------- 24 | rm "${thisdir}"/../../changes/*-*.md 25 | -------------------------------------------------------------------------------- /samples/external/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | External 5 | 6 | 7 | 8 |

Links

9 |
10 | A normal link 11 |
12 |
13 | target _blank 14 |
15 |
16 | target _top 17 |
18 |
19 | target _parent 20 |
21 |
22 | target _self 23 |
24 |
25 | 26 |
27 | 28 | 29 |

iframe

30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | General: 2 | ✔ Let there be multiple files open at once @done (17-01-18 16:26) 3 | ✔ Basic form-saving support @done (17-01-19 09:49) 4 | ☐ Undo 5 | ☐ Redo 6 | ✔ Close @done (17-01-19 08:44) 7 | ☐ guest request a window size 8 | ☐ Double-click in finder opens in LHTML viewer 9 | ✔ Title updates whenever guest title updates @done (17-01-23 13:25) 10 | ✔ Show that no file is open when no file is open @done (17-01-18 16:27) 11 | ☐ Grey out "Save/Save as" when nothing is open 12 | ☐ Automatic updates 13 | ☐ Prompt to save before closing unsaved changes 14 | ☐ Prompt to save before reloading if unsaved changes 15 | 16 | Directory: 17 | ✔ Save @done (17-01-18 15:50) 18 | ✔ Reload @done (17-01-18 15:52) 19 | ✔ Save as @done (17-01-23 16:09) 20 | ✔ Close @done (17-01-19 08:44) 21 | 22 | Zipfile: 23 | ✔ Open @done (17-01-18 16:26) 24 | ☐ Reload (source original file) 25 | ✔ Save @done (17-01-18 16:26) 26 | ✔ Save as @done (17-01-23 16:09) 27 | ✔ Close @done (17-01-19 08:44) 28 | -------------------------------------------------------------------------------- /functest/e2e.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const {setDialogAnswer, setMessageBoxAnswer} = require('./mocks.js'); 3 | const fs = require('fs-extra'); 4 | const Path = require('path'); 5 | const {waitUntilEqual} = require('./util.js'); 6 | const Mocha = require('mocha'); 7 | const {app} = require('../src/main.js'); 8 | 9 | const Tmp = require('tmp'); 10 | Tmp.setGracefulCleanup(); 11 | 12 | app.on('ready', function() { 13 | let mocha = new Mocha(); 14 | let testDir = 'functest'; 15 | fs.readdirSync(testDir).filter(function(file){ 16 | // Only keep the .js files 17 | return file.substr(-3) === '.js' && Path.basename(file).startsWith('test_'); 18 | }).forEach(function(file){ 19 | mocha.addFile( 20 | Path.join(testDir, file) 21 | ); 22 | }); 23 | 24 | mocha.run(function(failures){ 25 | process.on('exit', function () { 26 | process.exit(failures && 1); // exit with non-zero status if there were failures 27 | }); 28 | process.exit(0); 29 | }); 30 | }) 31 | -------------------------------------------------------------------------------- /functest/util.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | 3 | 4 | module.exports.waitUntil = function (func, interval, timeout) { 5 | interval = interval || 100; 6 | timeout = timeout || 5000; 7 | return new Promise((resolve, reject) => { 8 | let tout; 9 | let loop; 10 | var stop = () => { 11 | clearTimeout(tout); 12 | clearInterval(loop); 13 | } 14 | 15 | // timeout 16 | tout = setTimeout(() => { 17 | stop(); 18 | reject(new Error('Timed out after ' + timeout + 'ms')); 19 | }, timeout); 20 | 21 | // repeatedly check 22 | loop = setInterval(() => { 23 | Promise.resolve(func()) 24 | .then(result => { 25 | if (result) { 26 | stop(); 27 | resolve(result); 28 | } 29 | }); 30 | }, interval); 31 | }) 32 | } 33 | 34 | module.exports.waitUntilEqual = function (func, value, interval, timeout) { 35 | return module.exports.waitUntil(() => { 36 | return Promise.resolve(func()) 37 | .then(result => { 38 | return result === value; 39 | }) 40 | }, interval, timeout); 41 | } -------------------------------------------------------------------------------- /test/cases/form/index.html: -------------------------------------------------------------------------------- 1 | 2 | Form Thing 3 | 4 | 5 |
6 | Name: 7 |
8 |
9 | Awake? 10 |
11 |
12 | Best color: 19 |
20 |
21 | Worst color: 28 |
29 |
30 | Password: 31 |
32 |
33 | A: 34 | B: 35 |
36 | 37 |
38 | A: 39 | B: 40 |
41 | 42 |
43 | A: 44 | B: 45 |
46 | -------------------------------------------------------------------------------- /src/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | LHTML Viewer 9 | 40 | 41 | 42 |
43 |
44 |
LHTML Viewer
45 |
vX.X.X
46 |
47 |
48 | 64 | 65 | -------------------------------------------------------------------------------- /dev/publish/combine_changes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # this isn't perfect, but it's close enough 4 | thisdir="$(dirname $0)" 5 | PROJECT_ROOT="${thisdir}/../../" 6 | CHANGE_ROOT="${PROJECT_ROOT}/changes" 7 | 8 | log() { 9 | echo >&2 $* 10 | } 11 | 12 | listtype() { 13 | for type in $@; do 14 | ls "${CHANGE_ROOT}"/${type}-*.md 2>/dev/null | sort 15 | done 16 | } 17 | 18 | #--------------------------------------------------------------- 19 | # Version header 20 | #--------------------------------------------------------------- 21 | version=$(cat "${thisdir}/../../package.json" | grep version | cut -d'"' -f4) 22 | echo >> _CHANGELOG.md 23 | if echo "$version" | egrep '^.*\.0\.0' > /dev/null; then 24 | # major version 25 | echo "# v${version}" 26 | elif echo "$version" | egrep '^.*\.*\.0' > /dev/null; then 27 | # minor version 28 | echo "## v${version}" 29 | else 30 | # bugfix version 31 | echo "### v${version}" 32 | fi 33 | echo 34 | 35 | #--------------------------------------------------------------- 36 | # Change body 37 | #--------------------------------------------------------------- 38 | 39 | for changefile in $(listtype break); do 40 | echo "- **BACKWARD INCOMPATIBLE:** $(cat $changefile)" 41 | echo 42 | done 43 | 44 | for changefile in $(listtype fix); do 45 | echo "- **FIX:** $(cat $changefile)" 46 | echo 47 | done 48 | 49 | for changefile in $(listtype feature new); do 50 | echo "- NEW: $(cat $changefile)" 51 | echo 52 | done 53 | 54 | for changefile in $(listtype refactor info doc); do 55 | echo "- $(cat $changefile)" 56 | echo 57 | done 58 | 59 | -------------------------------------------------------------------------------- /samples/sizing/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Sizing 5 | 44 | 45 | 46 |
47 |
Left nav
48 |
49 |
The header
50 |
The body 51 | 52 | 53 | 54 | 55 |
56 |
The footer
57 |
58 |
Right nav
59 |
60 | 61 | -------------------------------------------------------------------------------- /src/lhtml_container.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | LHTML 10 | 16 | 17 | 18 | 19 | 62 | 63 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## v0.5.0 4 | 5 | - **BACKWARD INCOMPATIBLE:** Changes for the CHANGELOG are no longer sourced from git commit messages. 6 | 7 | - **BACKWARD INCOMPATIBLE:** Removed `LHTML.saving.defaultSaver` in favor of `LHTML.saving.onBeforeSave` 8 | 9 | - **BACKWARD INCOMPATIBLE:** Renamed `LHTML.saving.disableFormSaving` to `LHTML.saving.disableFormSync` 10 | 11 | - **BACKWARD INCOMPATIBLE:** Removed `LHTML.saving.registerSaver` in favor of `LHTML.saving.onBeforeSave` 12 | 13 | - **FIX:** When a document is reloaded, the document edited state is reset to false 14 | 15 | - NEW: Changes for the CHANGELOG are now sourced from files rather than from git commit messages. 16 | 17 | - NEW: Added safeguards to make sure the document and main process aren't trying to write/save at the same time 18 | 19 | - NEW: Reduce logging noise 20 | 21 | - NEW: Allow documents to access blob: URLs 22 | 23 | - NEW: `writeFile`/`readFile` accept the same arguments as Node `fs` equivalents. 24 | 25 | - NEW: There are working, functional tests of some saving/loading scenarios. 26 | 27 | - NEW: `fs.listdir` now accepts path and `{recursive:false}` arguments. 28 | 29 | - NEW: Added `saving.onBeforeSave` to replace duplicate methods of saving files 30 | 31 | - NEW: Add 'Export As PDF...' menu item 32 | 33 | - Moved API documentation out to `api.md` 34 | 35 | 36 | 37 | ## v0.4.0 38 | 39 | - NEW: Add 'New From Template...' menu option 40 | 41 | - NEW: Add Save As Template 42 | 43 | - NEW: menu items are enabled/disabled where pertinent 44 | 45 | - Reduce logging noise and roll log file 46 | 47 | - only send document-changed state when the state changes 48 | 49 | 50 | ### v0.3.5 51 | 52 | - NEW: Add system for updating CHANGELOG.md from git log 53 | 54 | 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lhtml", 3 | "productName": "LHTML", 4 | "version": "0.5.0-post", 5 | "main": "src/main.js", 6 | "description": "LHTML Viewer", 7 | "author": "Matt Haggard ", 8 | "scripts": { 9 | "test": "test/runtests.sh", 10 | "pack": "build --dir", 11 | "dist": "build", 12 | "postinstall": "install-app-deps", 13 | "start": "electron ." 14 | }, 15 | "devDependencies": { 16 | "electron": "^1.6.0", 17 | "electron-builder": "^13.6.0", 18 | "electron-packager": "^8.5.0", 19 | "esdoc": "^0.5.2", 20 | "mocha": "^3.2.0" 21 | }, 22 | "dependencies": { 23 | "adm-zip": "^0.4.7", 24 | "bluebird": "^3.4.7", 25 | "electron-is": "^2.4.0", 26 | "electron-log": "^1.3.0", 27 | "electron-updater": "^1.6.4", 28 | "fs-extra": "^2.0.0", 29 | "jquery": "^3.2.1", 30 | "klaw": "^1.3.1", 31 | "lodash": "^4.17.4", 32 | "tmp": "0.0.31" 33 | }, 34 | "build": { 35 | "appId": "com.github.iffy.lhtml", 36 | "mac": { 37 | "category": "your.app.category.type", 38 | "target": [ 39 | "zip", 40 | "dmg" 41 | ] 42 | }, 43 | "dmg": { 44 | "icon": "build/dmg.icns" 45 | }, 46 | "files": [ 47 | "!dev${/*}", 48 | "!samples${/*}", 49 | "!demos${/*}", 50 | "!doc${/*}", 51 | "!test${/*}", 52 | "!functest${/*}" 53 | ], 54 | "fileAssociations": [ 55 | { 56 | "ext": "lhtml", 57 | "role": "Editor", 58 | "icon": "build/lhtmldoc.icns" 59 | } 60 | ], 61 | "linux": { 62 | "target": [ 63 | "deb", 64 | "tar.gz" 65 | ] 66 | }, 67 | "nsis": { 68 | "perMachine": true 69 | } 70 | }, 71 | "repository": "git@github.com:iffy/lhtml.git", 72 | "license": "Apache-2.0", 73 | "optionalDependencies": { 74 | "fs-xattr": "^0.1.15" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/prefs/prefs.js: -------------------------------------------------------------------------------- 1 | let {BrowserWindow, app, remote} = require('electron'); 2 | const fs = require('fs-extra'); 3 | const Path = require('path'); 4 | const _ = require('lodash'); 5 | 6 | if (remote) { 7 | // running in a renderer process 8 | app = remote.app; 9 | } 10 | 11 | let win; 12 | 13 | function settingsFilename() { 14 | let userdatapath = app.getPath('userData'); 15 | return Path.join(userdatapath, 'preferences.json'); 16 | } 17 | 18 | function showPreferenceWindow() { 19 | if (win) { 20 | win.focus(); 21 | return win; 22 | } 23 | win = new BrowserWindow({ 24 | width: 300, 25 | height: 100, 26 | resizable: false, 27 | show: false, 28 | }); 29 | win.on('ready-to-show', () => { 30 | win.show(); 31 | }) 32 | win.on('closed', () => { 33 | win = null; 34 | }); 35 | win.on('focus', () => { 36 | }) 37 | win.loadURL(`file://${__dirname}/prefs.html#${settingsFilename()}`); 38 | return win; 39 | } 40 | 41 | let PREFERENCES = { 42 | max_doc_size: { 43 | init: 10, 44 | sanitize: x => { 45 | let ret = 0; 46 | try { 47 | ret = parseInt(x); 48 | } catch(err) {} 49 | return ret < 5 ? 5 : ret; 50 | }, 51 | }, 52 | } 53 | 54 | function getDefaultPrefs() { 55 | return _(PREFERENCES) 56 | .map((value, key) => { 57 | return [key, value.init]; 58 | }) 59 | .zipObject(); 60 | } 61 | 62 | function getPrefValue(key) { 63 | // XXX cache this 64 | let current_settings = getDefaultPrefs(); 65 | try { 66 | current_settings = fs.readJsonSync(settingsFilename()) 67 | } catch(err) { 68 | console.info('Error reading preferences.json:', err); 69 | } 70 | return current_settings[key]; 71 | } 72 | 73 | function sanitizePref(key, value) { 74 | let def = PREFERENCES[key]; 75 | if (!def) { 76 | // no such preference 77 | throw new Error(`No such preference: ${key}`); 78 | return; 79 | } 80 | return def.sanitize(value); 81 | } 82 | 83 | function sanitizePrefs(prefs) { 84 | let result = {}; 85 | _.each(prefs, (v, k) => { 86 | try { 87 | result[k] = sanitizePref(k, v); 88 | } catch(err) { 89 | console.error('Error sanitizing pref:', k, err); 90 | } 91 | }) 92 | return result; 93 | } 94 | 95 | module.exports = {showPreferenceWindow, getPrefValue, getDefaultPrefs, sanitizePref, sanitizePrefs}; 96 | -------------------------------------------------------------------------------- /src/prefs/prefs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | LHTML Preferences 5 | 40 | 41 | 42 |
43 |
44 | 45 | 46 | 47 | 50 | 51 | 52 | 53 | 54 | 55 |
Maximum document size: 48 | MB 49 |
56 |
57 |
58 | 102 | 103 | -------------------------------------------------------------------------------- /src/rpc.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) The LHTML team 2 | // See LICENSE for details. 3 | 4 | const log = require('electron-log'); 5 | 6 | class RPCService { 7 | constructor(listener, options) { 8 | options = options || {}; 9 | this.handlers = {}; 10 | this.listener = listener; 11 | this.DEFAULT_RPC_TARGET = options.default_target || null; 12 | this.DEFAULT_RESPONSE_RECEIVER = options.default_receiver || null; 13 | this._message_id = 0; 14 | this._pending = {}; 15 | this._sender_id = options.sender_id || null; 16 | } 17 | call(method, params, target) { 18 | target = target || this.DEFAULT_RPC_TARGET; 19 | if (!target) { 20 | throw 'No target given'; 21 | } 22 | let message_id = this._message_id++; 23 | let message = { 24 | method: method, 25 | params: params, 26 | id: message_id, 27 | }; 28 | if (this._sender_id) { 29 | message.sender_id = this._sender_id; 30 | } 31 | log.info(`RPC[${message_id}] ${message.method} call`); 32 | log.debug(`RPC[${message_id}] params`, params); 33 | return new Promise((resolve, reject) => { 34 | this._pending[message_id] = { 35 | resolve: resolve, 36 | reject: reject, 37 | }; 38 | target.send('rpc', message) 39 | }) 40 | } 41 | listen() { 42 | this.listener.on('rpc', (event, message) => { 43 | return this.request_received(event, message); 44 | }); 45 | this.listener.on('rpc-response', (event, response) => { 46 | return this.response_received(event, response); 47 | }); 48 | return this; 49 | } 50 | request_received(event, message) { 51 | log.info(`RPC[${message.id}] ${message.method} req`); 52 | log.debug(`RPC[${message.id}] message`, message); 53 | let receiver = this.DEFAULT_RESPONSE_RECEIVER || event.sender; 54 | let handler = this.handlers[message.method]; 55 | if (!handler) { 56 | receiver.send('rpc-response', { 57 | id: message.id, 58 | error: 'No such method: ' + message.method, 59 | }) 60 | } else { 61 | let ctx = { 62 | sender_id: message.sender_id, 63 | } 64 | let response; 65 | try { 66 | response = Promise.resolve(handler(ctx, message.params)) 67 | } catch(err) { 68 | response = Promise.reject(err); 69 | } 70 | return response.then((result) => { 71 | log.info(`RPC[${message.id}] ${message.method} done`); 72 | log.debug(`RPC[${message.id}] -> result`, result); 73 | receiver.send('rpc-response', { 74 | id: message.id, 75 | result: result, 76 | }) 77 | }, (error) => { 78 | log.info(`RPC[${message.id}] ${message.method} error`); 79 | log.debug(`RPC[${message.id}] -> error`, error); 80 | receiver.send('rpc-response', { 81 | id: message.id, 82 | error: error, 83 | }) 84 | }) 85 | } 86 | } 87 | response_received(event, response) { 88 | log.info(`RPC[${response.id}] response`); 89 | log.debug(`RPC[${response.id}] <- response`, response); 90 | var handler = this._pending[response.id]; 91 | delete this._pending[response.id]; 92 | if (response.error) { 93 | handler.reject(response.error); 94 | } else { 95 | handler.resolve(response.result); 96 | } 97 | } 98 | } 99 | 100 | module.exports = {}; 101 | module.exports.RPCService = RPCService; 102 | -------------------------------------------------------------------------------- /test/test_locks.js: -------------------------------------------------------------------------------- 1 | const Promise = require('bluebird'); 2 | const assert = require('assert'); 3 | const _ = require('lodash'); 4 | const {GroupSemaphore} = require('../src/locks.js'); 5 | 6 | class Deferred { 7 | constructor() { 8 | this.done = false; 9 | this.is_error = false; 10 | this.result = null; 11 | this.promise = new Promise((resolve, reject) => { 12 | this.resolve = (arg) => { 13 | this.done = true; 14 | this.result = arg; 15 | this.is_error = false; 16 | resolve(arg); 17 | } 18 | this.reject = (err) => { 19 | this.done = true; 20 | this.result = err; 21 | this.is_error = true; 22 | reject(err); 23 | } 24 | }) 25 | } 26 | } 27 | 28 | describe('GroupSemaphore', function() { 29 | describe('basically', function() { 30 | let sem; 31 | it('saves should be sequential', () => { 32 | sem = new GroupSemaphore({save: 'single', io: 'multiple'}); 33 | let called = []; 34 | let r1 = sem.acquire('save'); 35 | assert.ok(r1.isResolved(), "Should have acquired immediately"); 36 | 37 | let r2 = sem.acquire('save'); 38 | assert.ok(r2.isPending(), "Should be waiting"); 39 | 40 | sem.release('save'); 41 | assert.ok(r2.isResolved(), "Should now acquire second one"); 42 | }) 43 | 44 | it('save + io should be sequential', () => { 45 | sem = new GroupSemaphore({save: 'single', io: 'multiple'}); 46 | let called = []; 47 | let r1 = sem.acquire('save'); 48 | assert.ok(r1.isResolved(), "Should have acquired immediately"); 49 | 50 | let r2 = sem.acquire('io'); 51 | assert.ok(r2.isPending(), "Should be waiting"); 52 | 53 | sem.release('save'); 54 | assert.ok(r2.isResolved(), "Should now acquire second one"); 55 | }) 56 | 57 | it('io + save should be sequential', () => { 58 | sem = new GroupSemaphore({save: 'single', io: 'multiple'}); 59 | let called = []; 60 | let r1 = sem.acquire('io'); 61 | assert.ok(r1.isResolved(), "Should have acquired immediately"); 62 | 63 | let r2 = sem.acquire('save'); 64 | assert.ok(r2.isPending(), "Should be waiting"); 65 | 66 | sem.release('io'); 67 | assert.ok(r2.isResolved(), "Should now acquire second one"); 68 | }) 69 | 70 | it('io + io should be parallel', () => { 71 | sem = new GroupSemaphore({save: 'single', io: 'multiple'}); 72 | let called = []; 73 | let r1 = sem.acquire('io'); 74 | assert.ok(r1.isResolved(), "Should have acquired immediately"); 75 | 76 | let r2 = sem.acquire('io'); 77 | assert.ok(r2.isResolved(), "Should have acquired immediately"); 78 | }) 79 | 80 | it('io + io + save + io should be parallel then sequential', () => { 81 | sem = new GroupSemaphore({save: 'single', io: 'multiple'}); 82 | let called = []; 83 | let r1 = sem.acquire('io'); 84 | assert.ok(r1.isResolved(), "Should have acquired immediately"); 85 | 86 | let r2 = sem.acquire('io'); 87 | assert.ok(r2.isResolved(), "Should have acquired immediately"); 88 | 89 | let r3 = sem.acquire('save'); 90 | assert.ok(r3.isPending(), "Should wait"); 91 | 92 | let r4 = sem.acquire('io'); 93 | assert.ok(r4.isPending(), "Should wait"); 94 | 95 | sem.release('io') 96 | sem.release('io') 97 | assert.ok(r3.isResolved(), 'waiting save should be acquired'); 98 | assert.ok(r4.isPending(), "next io should still wait") 99 | }) 100 | }); 101 | }); -------------------------------------------------------------------------------- /src/updates.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 |   9 | 66 | 67 | 68 |
69 | 70 |
LHTML
71 |
vX.Y.Z
72 |
73 | 74 |
75 |
76 |
77 |
78 |
79 | 80 |
81 |
82 | 138 | 139 | -------------------------------------------------------------------------------- /src/locks.js: -------------------------------------------------------------------------------- 1 | const Promise = require('bluebird'); 2 | const _ = require('lodash'); 3 | 4 | 5 | class GroupSemaphore { 6 | // 7 | // groups is an object whose keys are group names 8 | // and whose values are one of: 9 | // 'single' - to only allow one thing to run at a time 10 | // 'multiple' - to allow multiple things of the same group 11 | // to run at the same time. 12 | constructor(groups) { 13 | this.flag_holder = null; 14 | this.flags_held = 0; 15 | this.groups = groups 16 | this.queue = []; 17 | } 18 | acquire(group) { 19 | return new Promise((resolve, reject) => { 20 | this.queue.push({group, resolve, reject}); 21 | this.pump(); 22 | }) 23 | } 24 | release(group) { 25 | if (!this.flag_holder || this.flag_holder !== group) { 26 | throw new Error("Trying to release a job that wasn't acquired.") 27 | } 28 | this.flags_held -= 1; 29 | if (this.flags_held === 0) { 30 | // done with all jobs of this type 31 | this.flag_holder = null; 32 | this.pump(); 33 | } 34 | } 35 | run(group, func) { 36 | return this.acquire(group) 37 | .then(() => { 38 | return func(); 39 | }) 40 | .then(result => { 41 | this.release(group); 42 | return result; 43 | }, err => { 44 | this.release(group); 45 | throw err; 46 | }) 47 | } 48 | pump() { 49 | if (!this.queue.length) { 50 | return; 51 | } 52 | if (!this.flag_holder) { 53 | // nothing is running 54 | this._serviceNext(); 55 | this.pump(); 56 | } else { 57 | // something is running 58 | let flag_group_type = this.groups[this.flag_holder] || 'single'; 59 | if (flag_group_type === 'multiple') { 60 | // the currently running thing allows parallel running 61 | if (this.flag_holder === this.queue[0].group) { 62 | // next thing is in matching group 63 | this._serviceNext(); 64 | this.pump(); 65 | } 66 | } 67 | } 68 | } 69 | _serviceNext() { 70 | let acquiree = this.queue.shift(); 71 | this.flag_holder = acquiree.group; 72 | this.flags_held += 1; 73 | acquiree.resolve(true); 74 | } 75 | } 76 | 77 | 78 | class RPCLock { 79 | constructor(rpc) { 80 | this.rpc = rpc; 81 | this.locks_held = 0; 82 | this.pending_acquire = false; 83 | this.pending_queue = []; 84 | } 85 | run(func) { 86 | return this.acquire() 87 | .then(() => { 88 | return func(); 89 | }) 90 | .then(result => { 91 | return this.release().then(() => { 92 | return result; 93 | }) 94 | }, err => { 95 | return this.release().then(() => { 96 | throw err; 97 | }); 98 | }) 99 | } 100 | acquire() { 101 | if (this.locks_held > 0) { 102 | // already have a lock 103 | this.locks_held += 1 104 | return Promise.resolve(true); 105 | } else { 106 | // no lock yet 107 | if (!this.pending_acquire) { 108 | this.pending_acquire = true; 109 | this.rpc.call('acquire_io_lock') 110 | .then(() => { 111 | this._flush_pending(); 112 | }) 113 | } 114 | return new Promise((resolve, reject) => { 115 | this.pending_queue.push({resolve, reject}); 116 | }); 117 | } 118 | } 119 | _flush_pending() { 120 | this.pending_acquire = false; 121 | _.each(this.pending_queue, p => { 122 | this.locks_held += 1; 123 | p.resolve(true) 124 | }); 125 | this.pending_queue = []; 126 | } 127 | release() { 128 | this.locks_held -= 1; 129 | if (this.locks_held < 0) { 130 | throw new Error('Attempting to release too much'); 131 | } else if (this.locks_held == 0) { 132 | return this.rpc.call('release_io_lock'); 133 | } else { 134 | return Promise.resolve(true); 135 | } 136 | } 137 | } 138 | 139 | module.exports = {GroupSemaphore, RPCLock}; 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | [![Build Status](https://travis-ci.org/iffy/lhtml.svg?branch=master)](https://travis-ci.org/iffy/lhtml) 7 | 8 | - [LHTML document author API documentation](api.md) 9 | 10 | # LHTML 11 | 12 | An LHTML file is a packaged web application with the ability to save itself. Think PDFs but with web technologies. For a demo, [watch this video](https://www.youtube.com/watch?v=QiAbkCHHefo): 13 | 14 | [![LHTML Demo](https://img.youtube.com/vi/QiAbkCHHefo/0.jpg)](https://www.youtube.com/watch?v=QiAbkCHHefo) 15 | 16 | 17 | **The current application is considered Alpha-quality. Use at your own risk, and all that.** 18 | 19 | # Installation 20 | 21 | - [Download the latest](https://github.com/iffy/lhtml/releases) 22 | - Or clone this repo and [build from source](#packaging) 23 | - Or clone this repo and follow the [development instructions](#development) 24 | 25 | # Making LHTML files 26 | 27 | To create an LHTML file, create an `index.html` file: 28 | 29 | ```html 30 | 31 | 32 | 33 | My LHTML file 34 | 35 | 36 | Name: 37 | 38 | 39 | ``` 40 | 41 | then zip it up. On Linux/macOS do: 42 | 43 | ```bash 44 | zip myfirst.lhtml index.html 45 | ``` 46 | 47 | Now you can open the file, type in the inputs, save it, email it, copy it, etc... 48 | 49 | ## Resources 50 | 51 | If you want to include CSS or JavaScript files, include them in the zip and reference them with relative paths. For example: 52 | 53 | ```html 54 | 55 | 56 | ... 57 | 58 | ... 59 | ``` 60 | 61 | ```bash 62 | zip myfirst.lhtml index.html style.css 63 | ``` 64 | 65 | ## External Resources 66 | 67 | LHTML files are not allowed to access resources over the network. This is intentional for security reasons. 68 | 69 | ## API 70 | 71 | LHTML viewers provide a small JavaScript API to `index.html` files within the `LHTML` object. Available endpoints are described in [api.md](api.md). 72 | 73 | # Why not just use Electron? 74 | 75 | This LHTML viewer is built with Electron, so I obviously think Electron is a good choice for making apps. And it may make more sense for you to use Electron if you need full filesystem access, network access or any of the other features Electron provides. 76 | 77 | But if you're making documents (or document-like things), you don't want to build and install an entirely new Electron app for each document. 78 | 79 | # How secure is this? 80 | 81 | Security of LHTML hasn't yet been fully vetted. Some precautions have been taken, but we should do a full security audit before you open Internet-stranger's LHTML files. Here's what we do: 82 | 83 | - All documents are loaded in a sandboxed [`` tag](http://electron.atom.io/docs/api/web-view-tag/) 84 | - Documents have no access to node stuff (if the sandbox of `` is working as designed) 85 | - Access to `file://` resources is forbidden to documents. 86 | - Access to `https?://` resources is forbidden to documents. 87 | - Documents shouldn't be able to open new windows (need to verify this for all cases), so they can't open fake system dialogs, hopefully. 88 | - Documents are limited in size (currently hard-coded at 10MB, but with plans to make it configurable) to prevent documents from filling up hard drives 89 | 90 | Insecurish things: 91 | 92 | - Currently, LHTML files are unzipped to a temporary directory, then zipped back up to overwrite the original. If an attacker sneaked something into that temporary directory, it would end up back in the zipped LHTML. 93 | 94 | # Development 95 | 96 | To run the application in development mode do: 97 | 98 | yarn install 99 | node_modules/.bin/electron . 100 | 101 | You can set the process and browser logging levels with the `LOGLEVEL` and `JS_LOGLEVEL` environment variables. 102 | 103 | LOGLEVEL=warn JS_LOGLEVEL=debug electron . 104 | 105 | # Packaging 106 | 107 | To do cross-platform builds, see [this guide](https://github.com/electron-userland/electron-builder/wiki/Multi-Platform-Build) 108 | 109 | To package the application, do one of these: 110 | 111 | build --win --mac --linux --ia32 --x64 112 | 113 | You can omit whichever arch/platform you don't need to build. 114 | 115 | # Releases 116 | 117 | To manually create a draft release, you'll need a `GH_TOKEN` with `repo` scope access. Generate one on GitHub (in Settings somewhere). Once you have the token do: 118 | 119 | GH_TOKEN="..." dev/publish/publish.sh 120 | 121 | Update `CHANGELOG.md` with: 122 | 123 | dev/publish/updatechangelog.sh 124 | 125 | -------------------------------------------------------------------------------- /demos/form/index.html: -------------------------------------------------------------------------------- 1 | 2 | Job Application 3 | 4 | 5 | 6 | 7 | 19 | 20 | 21 |
22 | 23 |
24 | 25 |
Contact Information
26 |
27 |
28 | 29 | 30 |
31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 |
39 |
40 | 41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 | 49 |
50 |
51 | 52 |
53 |
54 | 55 | 56 |
57 |
58 | 59 | 60 |
61 |
62 | 68 |
69 |
70 | 71 | 72 |
73 |
74 | 75 | Save
76 | 77 |
78 | 79 |
80 | 81 |
-------------------------------------------------------------------------------- /src/guest/formsync.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) The LHTML team 2 | // See LICENSE for details. 3 | 4 | const _ = require('lodash'); 5 | const log = require('electron-log'); 6 | 7 | module.exports = {}; 8 | 9 | let observer; 10 | let change_handlers = []; 11 | 12 | function addMultiListener(node, events, listener) { 13 | events.split(' ').forEach(event => { 14 | node.addEventListener(event, listener, false); 15 | }); 16 | } 17 | function removeMultiListener(node, events, listener) { 18 | events.split(' ').forEach(event => { 19 | node.removeEventListener(event, listener, false); 20 | }); 21 | } 22 | function emitChange(element, value) { 23 | _.each(change_handlers, handler => { 24 | handler({ 25 | element: element, 26 | value: value, 27 | }); 28 | }) 29 | } 30 | 31 | class ChangeList { 32 | constructor() { 33 | this._changes = []; 34 | } 35 | flush() { 36 | _.each(this._changes, change => { 37 | emitChange(change[0], change[1]); 38 | }) 39 | } 40 | add(element, value) { 41 | this._changes.push([element, value]); 42 | } 43 | } 44 | 45 | //---------------------------------------------------- 46 | // handlers 47 | //---------------------------------------------------- 48 | function onChange_radio(ev) { 49 | let node = this; 50 | let changes = new ChangeList(); 51 | var all_radios = document.getElementsByName(node.name); 52 | all_radios.forEach(radio => { 53 | if (radio.type === 'radio' && 54 | radio.form === node.form && 55 | radio.hasAttribute('checked') && 56 | radio !== node) { 57 | changes.add(radio, false); 58 | radio.removeAttribute('checked'); 59 | } 60 | }); 61 | if (!node.hasAttribute('checked')) { 62 | changes.add(node, true); 63 | } 64 | node.setAttribute('checked', true); 65 | changes.flush(); 66 | } 67 | function onChange_input(ev) { 68 | let node = this; 69 | let changes = new ChangeList(); 70 | if ('' + node.getAttribute('value') !== '' + node.value) { 71 | changes.add(node, node.value); 72 | } 73 | node.setAttribute('value', node.value); 74 | changes.flush(); 75 | } 76 | function onChange_checkbox(ev) { 77 | let node = this; 78 | if (node.checked) { 79 | node.setAttribute('checked', true); 80 | emitChange(node, true); 81 | } else { 82 | node.removeAttribute('checked'); 83 | emitChange(node, false); 84 | } 85 | } 86 | function onChange_select(ev) { 87 | let node = this; 88 | let changes = new ChangeList(); 89 | node.querySelectorAll('option').forEach(option => { 90 | if (option.selected) { 91 | if (!option.hasAttribute('selected')) { 92 | changes.add(option, true); 93 | option.setAttribute('selected', 'true'); 94 | } 95 | } else { 96 | if (option.hasAttribute('selected')) { 97 | changes.add(option, false); 98 | option.removeAttribute('selected'); 99 | } 100 | } 101 | }); 102 | changes.flush(); 103 | } 104 | 105 | //---------------------------------------------------- 106 | // mirror 107 | //---------------------------------------------------- 108 | function adjustValueToAttributeMirroring(node, action) { 109 | let func = action === 'remove' ? removeMultiListener : addMultiListener; 110 | if (node.nodeName === 'INPUT') { 111 | if (node.type === 'checkbox') { 112 | func(node, 'change', onChange_checkbox); 113 | } else if (node.type === 'radio') { 114 | func(node, 'change', onChange_radio); 115 | } else { 116 | func(node, 'change keyup', onChange_input); 117 | } 118 | } else if (node.nodeName === 'SELECT') { 119 | func(node, 'change', onChange_select); 120 | } 121 | } 122 | 123 | let onChange = (func) => { 124 | change_handlers.push(func); 125 | } 126 | 127 | let enable = () => { 128 | if (observer) { 129 | log.warn('LHTML: form saving already enabled'); 130 | return; 131 | } 132 | observer = new MutationObserver(mutations => { 133 | mutations.forEach(mutation => { 134 | if (mutation.type === 'childList') { 135 | mutation.addedNodes.forEach(node => { 136 | adjustValueToAttributeMirroring(node, 'add'); 137 | }) 138 | } 139 | }) 140 | }); 141 | // Catch all existing elements 142 | _.each(document.getElementsByTagName('input'), (elem) => { 143 | adjustValueToAttributeMirroring(elem, 'add'); 144 | }); 145 | _.each(document.getElementsByTagName('select'), (elem) => { 146 | adjustValueToAttributeMirroring(elem, 'add'); 147 | }); 148 | observer.observe(document.body, { 149 | childList: true, 150 | subtree: true, 151 | }); 152 | log.info('LHTML: form saving enabled'); 153 | } 154 | 155 | let disable = () => { 156 | if (!observer) { 157 | // already disabled 158 | return; 159 | } 160 | observer.disconnect(); 161 | _.each(document.getElementsByTagName('input'), (elem) => { 162 | adjustValueToAttributeMirroring(elem, 'remove'); 163 | }); 164 | _.each(document.getElementsByTagName('select'), (elem) => { 165 | adjustValueToAttributeMirroring(elem, 'remove'); 166 | }); 167 | observer = null; 168 | } 169 | 170 | module.exports.enable = enable; 171 | module.exports.disable = disable; 172 | module.exports.onChange = onChange; 173 | 174 | -------------------------------------------------------------------------------- /api.md: -------------------------------------------------------------------------------- 1 | # LHTML Document API 2 | 3 | These are the functions available to authors of LHTML documents available in the `window.LHTML` namespace (as shown in the examples). 4 | 5 | | Function/Variable | Short description | 6 | |---|---| 7 | | [`fs.listdir(...)`](#fslistdir) | List contents of the zip | 8 | | [`fs.readFile(...)`](#fsreadfile) | Read a file from the document zip | 9 | | [`fs.remove(...)`](#fsremove) | Remove a file/dir from the document zip | 10 | | [`fs.writeFile(...)`](#fswritefile) | Overwrite a file within the document zip | 11 | | [`on(...)`](#on) | Listen for events | 12 | | [`saving.disableFormSync()`](#savingdisableformsync) | Called to disable automatic form saving | 13 | | [`saving.exportFile(...)` ](#savingexportfile) | Export data to be saved as a file by the user | 14 | | [`saving.onBeforeSave`](#savingonbeforesave) | Function called just prior to saving the file. | 15 | | [`saving.save()`](#savingsave) | Programatically start saving the current document | 16 | | [`saving.setDocumentEdited(...)`](#savingsetdocumentedited) | Indicate that there are changes to be saved | 17 | | [`suggestSize(...)`](#suggestsize) | Attempt to resize the document's window | 18 | 19 | ### `fs.listdir(...)` 20 | 21 | List the contents of the LHTML zip file. If called without arguments, you will receive a list of every file within the LHTML zip file. 22 | 23 | Spec: `fs.listdir([path,] [options])` 24 | 25 | | Parameter | Description | 26 | |---|---| 27 | | `path` | Path (relative to document zip root) to list. Defaults to `/` | 28 | | `options` | Object of options | 29 | | `options.recursive` | If `true` (the default) then recursively list the directory tree. | 30 | 31 | Returns a list of objects with the following members: 32 | 33 | | Key | Description | 34 | |---|---| 35 | | `name` | Base name of file/dir | 36 | | `path` | Full relative path of file/dir | 37 | | `dir` | Full relative path of containing dir | 38 | | `size` | Size of file/dir in bytes | 39 | | `isdir` | Optional. `true` if this item is a directory, otherwise `undefined` | 40 | 41 | Usage: 42 | 43 | ```javascript 44 | window.LHTML && LHTML.fs.listdir().then(function(items) { 45 | items.foreach(function(item) { 46 | console.log(item.path + ': ' + item.size + 'B'); 47 | }); 48 | }); 49 | ``` 50 | 51 | ### `fs.readFile(...)` 52 | 53 | Same as [Node's `fs.readFile`](https://nodejs.org/api/fs.html#fs_fs_readfile_file_options_callback) except that it returns a Promise. 54 | 55 | Read an entire file's contents. 56 | 57 | Usage: 58 | 59 | ```javascript 60 | window.LHTML && LHTML.fs.readFile('something.txt').then(function(contents) { 61 | console.log('something.txt contains:'); 62 | console.log(contents); 63 | }) 64 | ``` 65 | 66 | ### `fs.remove(...)` 67 | 68 | Spec: `fs.remove(path)` 69 | 70 | Delete a file/directory from the zipfile. 71 | 72 | **You must call `saving.save()` afterward if you want the deletion to be permanent.** 73 | 74 | Usage: 75 | 76 | ```javascript 77 | window.LHTML && LHTML.fs.remove('foo.txt').then(function() { 78 | return LHTML.save(); 79 | }) 80 | ``` 81 | 82 | ### `fs.writeFile(...)` 83 | 84 | Same as [Node's `fs.writeFile`](https://nodejs.org/api/fs.html#fs_fs_writefile_file_data_options_callback) except: 85 | 86 | - it returns a Promise 87 | - if the directory of the file doesn't exist, it is created 88 | - it limits the size of what you can write 89 | 90 | **You must call `saving.save()` afterward if you want the writing to be permanent.** 91 | 92 | Usage: 93 | 94 | ```javascript 95 | window.LHTML && LHTML.fs.writeFile('foo.txt', 'guts').then(function() { 96 | return LHTML.save(); 97 | }); 98 | ``` 99 | 100 | ### `on(...)` 101 | 102 | `on(event, handler)` 103 | 104 | Listen for one of these events: 105 | 106 | - `saved` - emitted after the document has been saved. The handler is called with no arguments. 107 | - `save-failed` - emitted if an attempted save fails. The handler is called with a string description. 108 | 109 | Usage: 110 | 111 | ```javascript 112 | window.LHTML && LHTML.on('saved', function() { 113 | console.log('The file was saved!'); 114 | }) 115 | window.LHTML && LHTML.on('save-failed', function() { 116 | console.log('Save failed :('); 117 | }) 118 | ``` 119 | 120 | ### `saving.disableFormSync()` 121 | 122 | A common use case for LHTML files is to present a form to be filled out. Therefore, by default data entered into forms will be synchsaved. If you want to disable this automatic saving (because you're using a framework like React or Angular) call `saving.disableFormSync()`. 123 | 124 | Usage: 125 | 126 | ```html 127 | 128 | 129 | 130 | Name: 131 | Email: 132 | Favorite color: 136 | 137 | ``` 138 | 139 | ### `saving.exportFile(...)` 140 | 141 | `saving.exportFile(filename, content, [encoding='utf8'])` 142 | 143 | Prompt the user to save a file to their disk (outside the LHTML document). **Currently this may only be called in response to a click event.** 144 | 145 | Returns a Promise that will contain the `filename` the user chose. 146 | 147 | Usage: 148 | 149 | ```html 150 | 151 | 160 | ``` 161 | 162 | ### `saving.onBeforeSave(...)` 163 | 164 | This function is called just prior to saving the LHTML document. Overwrite this function with one of your own to change how saving is done. 165 | 166 | By default, this function will pack up the current state of `index.html` and write it to `index.html` in the LHTML zip. 167 | 168 | If this function returns a Promise, the document will not be saved until the promise resolves. 169 | 170 | Example: 171 | 172 | ```javascript 173 | if (window.LHTML) { 174 | LHTML.saving.disableFormSync(); 175 | LHTML.saving.onBeforeSave = function() { 176 | return LHTML.fs.writeFile('data.json', '{"name": "bob"}'); 177 | } 178 | } 179 | ``` 180 | 181 | Another example that does the default behavior in addition to a custom one: 182 | 183 | ```javascript 184 | if (window.LHTML) { 185 | let original_saver = LHTML.saving.onBeforeSave; 186 | LHTML.saving.onBeforeSave = function() { 187 | return original_saver() 188 | .then(function() { 189 | return LHTML.fs.writeFile('data.json', '{"name": "bob"}'); 190 | }) 191 | } 192 | } 193 | ``` 194 | 195 | ### `saving.save()` 196 | 197 | Initiates a save of the current file. Returns a Promise that fires when the save is finished 198 | 199 | See `saving.onBeforeSave` for more info. 200 | 201 | Usage: 202 | 203 | ```html 204 | 209 | ``` 210 | 211 | ### `saving.setDocumentEdited(...)` 212 | 213 | `saving.setDocumentEdited(value)` 214 | 215 | If form-saving is enabled (which it is by default and unless `saving.disableFormSync()` is called) then document edited state is handled automatically. This function is mostly useful for documents with form-saving disabled. 216 | 217 | Calling this function sets the edited state of the current document. Before closing an edited document, the application will prompt to save. Set this to `true` to prevent closing without a prompt. Set to `false` if there are no changes to be saved. 218 | 219 | Also, every time a document is saved, the edited state is automatically reset to `false`. 220 | 221 | Usage: 222 | 223 | ```html 224 | 227 | ``` 228 | 229 | ### `suggestSize(...)` 230 | 231 | `suggestSize(width, height)` 232 | 233 | Suggest that the document be the given size (in pixels). It will promise an object with the actual width and height the window was resized to. The resulting size will differ from the suggested size when the suggested size is too small or too large (as determined by the LHTML viewer). 234 | 235 | Usage: 236 | 237 | ```html 238 | 243 | ``` -------------------------------------------------------------------------------- /functest/test_e2e.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | // var chai = require('chai'); 3 | // var chaiAsPromised = require('chai-as-promised'); 4 | const {mock, setDialogAnswer, setMessageBoxAnswer} = require('./mocks.js'); 5 | var fs = require('fs-extra'); 6 | var Path = require('path'); 7 | const {app, openPath, saveFocusedDoc, saveAsFocusedDoc, closeFocusedDoc, reloadFocusedDoc} = require('../src/main.js'); 8 | const {waitUntil, waitUntilEqual} = require('./util.js'); 9 | const {webContents, BrowserWindow} = require('electron'); 10 | const AdmZip = require('adm-zip'); 11 | 12 | const Tmp = require('tmp'); 13 | Tmp.setGracefulCleanup(); 14 | 15 | const _ = require('lodash'); 16 | 17 | function _wrapFunction(func) { 18 | if (_.isFunction(func)) { 19 | return `(function() { 20 | try { 21 | return (${func.toString()})(); 22 | } catch(err) { 23 | return {executeJavaScriptError:err.toString()}; 24 | } 25 | })()`; 26 | } 27 | return func; 28 | } 29 | 30 | function executeJavaScript(web, func) { 31 | let code = _wrapFunction(func) 32 | return web.executeJavaScript(code, /* because of issue 8743 */()=>{}) 33 | .then(result => { 34 | if (result && result.executeJavaScriptError) { 35 | throw new Error(result.executeJavaScriptError); 36 | } 37 | return result; 38 | }) 39 | .catch(err => { 40 | console.error('error', err); 41 | }) 42 | } 43 | 44 | function readFromZip(zipfile, path) { 45 | let zip = new AdmZip(zipfile); 46 | return zip.readFile(path); 47 | } 48 | 49 | function nonDevToolsWebContents() { 50 | return _.filter(webContents.getAllWebContents(), wc => { 51 | return ! wc.getURL().startsWith('chrome-devtools://'); 52 | }) 53 | } 54 | 55 | function openDocument(path) { 56 | openPath(path); 57 | return waitUntil(function() { 58 | return BrowserWindow.getAllWindows().length == 1; 59 | }) 60 | .then(() => { 61 | return waitUntil(function() { 62 | return nonDevToolsWebContents().length == 2; 63 | }) 64 | }) 65 | .then(() => { 66 | return waitUntil(function() { 67 | return _.filter(webContents.getAllWebContents(), wc => { 68 | return wc.isLoading(); 69 | }).length === 0; 70 | }) 71 | }) 72 | .then(() => { 73 | let webview; 74 | let container; 75 | _.each(nonDevToolsWebContents(), wc => { 76 | if (wc.getURL().startsWith('lhtml://')) { 77 | webview = wc; 78 | } else { 79 | container = wc; 80 | } 81 | }) 82 | return { 83 | webview: webview, 84 | container: container, 85 | } 86 | }) 87 | } 88 | 89 | function reloadDocument() { 90 | reloadFocusedDoc() 91 | return waitUntil(function() { 92 | return _.filter(nonDevToolsWebContents(), wc => { 93 | return wc.isLoading(); 94 | }).length === 0; 95 | }) 96 | } 97 | 98 | assert.contains = function(haystack, needle) { 99 | let assertion; 100 | if (haystack === null || needle === null) { 101 | assertion = false; 102 | } else { 103 | assertion = haystack.indexOf(needle) !== -1 104 | } 105 | return assert.ok(assertion, `Expected to find\n-->${needle}<--\ninside\n-->${haystack}<--`) 106 | } 107 | assert.doesNotContain = function(haystack, needle) { 108 | let assertion; 109 | if (haystack === null || needle === null) { 110 | assertion = false; 111 | } else { 112 | assertion = haystack.indexOf(needle) === -1 113 | } 114 | return assert.ok(assertion, `Expected not to find\n-->${needle}<--\ninside\n-->${haystack}<--`); 115 | } 116 | 117 | describe('app launch', function() { 118 | this.timeout(10000); 119 | 120 | beforeEach(() => { 121 | }); 122 | afterEach(() => { 123 | return closeFocusedDoc(); 124 | }); 125 | 126 | //---------------------------------------------------------------------- 127 | // saving/loading 128 | //---------------------------------------------------------------------- 129 | describe('saving', function() { 130 | let workdir; 131 | beforeEach(() => { 132 | workdir = Tmp.dirSync({unsafeCleanup: true}).name; 133 | }); 134 | 135 | describe('from LHTML dir,', function() { 136 | let src_dir; 137 | let webview; 138 | let container; 139 | 140 | beforeEach(() => { 141 | src_dir = Path.join(workdir, 'src'); 142 | fs.ensureDirSync(src_dir); 143 | fs.writeFileSync(Path.join(src_dir, 'index.html'), 144 | ''); 145 | return openDocument(src_dir) 146 | .then(result => { 147 | webview = result.webview; 148 | container = result.container; 149 | }) 150 | }) 151 | 152 | it('"Save" should overwrite dir', () => { 153 | return executeJavaScript(webview, function() { 154 | return document.getElementById('theinput').setAttribute('value', 'jimbo'); 155 | }) 156 | .then(() => { 157 | return saveFocusedDoc() 158 | }) 159 | .then(() => { 160 | assert.contains(fs.readFileSync(Path.join(src_dir, 'index.html')), 161 | 'jimbo'); 162 | }) 163 | .then(() => { 164 | return reloadDocument() 165 | }) 166 | .then(() => { 167 | return executeJavaScript(webview, function() { 168 | return document.getElementById('theinput').getAttribute('value') 169 | }) 170 | }) 171 | .then(value => { 172 | assert.equal(value, 'jimbo'); 173 | }) 174 | }); 175 | 176 | it('"Save As" should create a new file', () => { 177 | let dst_file = Path.join(workdir, 'dst.lhtml'); 178 | return executeJavaScript(webview, function() { 179 | return document.getElementById('theinput').setAttribute('value', 'garbage'); 180 | }) 181 | .then(() => { 182 | setDialogAnswer(dst_file) 183 | return saveAsFocusedDoc() 184 | }) 185 | .then(() => { 186 | // Should not have overwritten original 187 | assert.doesNotContain(fs.readFileSync(Path.join(src_dir, 'index.html')), 188 | 'garbage'); 189 | }) 190 | .then(() => { 191 | // Should have written new file 192 | assert.contains(readFromZip(dst_file, './index.html'), 'garbage'); 193 | }) 194 | .then(() => { 195 | return reloadDocument() 196 | }) 197 | .then(() => { 198 | // Should be using the new file 199 | return executeJavaScript(webview, function() { 200 | return document.getElementById('theinput').getAttribute('value') 201 | }) 202 | }) 203 | .then(value => { 204 | assert.equal(value, 'garbage'); 205 | }) 206 | }); 207 | }); 208 | 209 | describe('from LHTML file', function() { 210 | let src_file; 211 | let src_dir; 212 | let webview; 213 | let container; 214 | 215 | beforeEach(() => { 216 | src_file = Path.join(workdir, 'src.lhtml'); 217 | src_dir = Path.join(workdir, 'src'); 218 | let zip = new AdmZip(); 219 | zip.addFile('./index.html', ''); 220 | zip.writeZip(src_file); 221 | 222 | return openDocument(src_file) 223 | .then(result => { 224 | webview = result.webview; 225 | container = result.container; 226 | }) 227 | }) 228 | 229 | it('"Save" should overwrite file', () => { 230 | return executeJavaScript(webview, function() { 231 | return document.getElementById('theinput').setAttribute('value', 'jimbo'); 232 | }) 233 | .then(() => { 234 | return saveFocusedDoc() 235 | }) 236 | .then(() => { 237 | assert.contains(readFromZip(src_file, './index.html'), 'jimbo'); 238 | }) 239 | .then(() => { 240 | return reloadDocument() 241 | }) 242 | .then(() => { 243 | return executeJavaScript(webview, function() { 244 | return document.getElementById('theinput').getAttribute('value') 245 | }) 246 | }) 247 | .then(value => { 248 | assert.equal(value, 'jimbo'); 249 | }) 250 | }); 251 | 252 | it('"Save As" should create a new file', () => { 253 | let dst_file = Path.join(workdir, 'dst.lhtml'); 254 | return executeJavaScript(webview, function() { 255 | return document.getElementById('theinput').setAttribute('value', 'garbage'); 256 | }) 257 | .then(() => { 258 | setDialogAnswer(dst_file) 259 | return saveAsFocusedDoc() 260 | }) 261 | .then(() => { 262 | // Should not have overwritten original 263 | assert.doesNotContain(readFromZip(src_file, './index.html'), 'garbage') 264 | }) 265 | .then(() => { 266 | // Should have written new file with data in it 267 | assert.contains(readFromZip(dst_file, './index.html'), 'garbage') 268 | }) 269 | .then(() => { 270 | return reloadDocument() 271 | }) 272 | .then(() => { 273 | // Should be using the new file 274 | return executeJavaScript(webview, function() { 275 | return document.getElementById('theinput').getAttribute('value') 276 | }) 277 | }) 278 | .then(value => { 279 | assert.equal(value, 'garbage'); 280 | }) 281 | }); 282 | }); 283 | }) 284 | }) 285 | 286 | 287 | 288 | 289 | 290 | -------------------------------------------------------------------------------- /src/guest/preload.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) The LHTML team 2 | // See LICENSE for details. 3 | 4 | // 5 | // This script is executed right when an LHTML file is loaded. 6 | // 7 | const {ipcRenderer, remote} = require('electron'); 8 | const {RPCService} = require('../rpc.js'); 9 | const _ = require('lodash'); 10 | const formsync = require('./formsync.js'); 11 | const fs = require('fs-extra'); 12 | const {ChrootFS, safe_join, copy_xattr} = require('../chrootfs.js'); 13 | const {RPCLock} = require('../locks.js'); 14 | const log = require('electron-log'); 15 | const electron_is = require('electron-is'); 16 | const {getPrefValue} = require('../prefs/prefs.js'); 17 | 18 | log.transports.console.level = process.env.JS_LOGLEVEL || 'warn'; 19 | 20 | let LHTML = {}; 21 | 22 | //---------------------------------------------------------------------------- 23 | // RPCService 24 | //---------------------------------------------------------------------------- 25 | 26 | 27 | var RPC = new RPCService(ipcRenderer, { 28 | default_target: ipcRenderer, 29 | default_receiver: ipcRenderer, 30 | sender_id: window.location.hostname, 31 | }); 32 | RPC.listen(); 33 | RPC.handlers = { 34 | echo: (ctx, data) => { 35 | return 'echo:' + data; 36 | }, 37 | save_your_stuff: (ctx, data) => { 38 | var p; 39 | if (LHTML.saving.onBeforeSave) { 40 | p = Promise.resolve(LHTML.saving.onBeforeSave()); 41 | } else { 42 | console.log('no onBeforeSave'); 43 | p = Promise.resolve({}) 44 | } 45 | return p.then(() => { 46 | TMP_DOC_EDITED = false; 47 | SAVING = true; 48 | return; 49 | }) 50 | }, 51 | set_chrootfs_root: (ctx, new_root) => { 52 | if (chfs) { 53 | chfs.setRoot(new_root); 54 | } 55 | }, 56 | emit_event: (ctx, data) => { 57 | var event = data.key; 58 | var event_data = data.data; 59 | _.each(EVENT_HANDLERS[data.key], (func) => { 60 | func(data.data); 61 | }); 62 | } 63 | } 64 | let rpc_lock = new RPCLock(RPC); 65 | 66 | //---------------------------------------------------------------------------- 67 | // Public API 68 | // 69 | // Every function on LHTML is available to guest files. 70 | //---------------------------------------------------------------------------- 71 | 72 | // 73 | // Register something to handle events. 74 | // 75 | // Some events: 76 | // saved 77 | let EVENT_HANDLERS = {}; 78 | LHTML.on = (event, handler) => { 79 | if (!EVENT_HANDLERS[event]) { 80 | EVENT_HANDLERS[event] = []; 81 | } 82 | EVENT_HANDLERS[event].push(handler); 83 | } 84 | 85 | //--------------------------- 86 | // FileSystem stuff 87 | //--------------------------- 88 | LHTML.fs = {}; 89 | let chfs; 90 | let fs_attrs = [ 91 | 'writeFile', 92 | 'readFile', 93 | 'remove', 94 | 'listdir', 95 | ] 96 | let pending = {}; 97 | _.each(fs_attrs, attr => { 98 | pending[attr] = []; 99 | LHTML.fs[attr] = (...args) => { 100 | return new Promise((resolve, reject) => { 101 | pending[attr].push({args, resolve, reject}); 102 | }) 103 | } 104 | }) 105 | 106 | const MEG = 2 ** 20; 107 | 108 | function ceilToNearest(x, nearest) { 109 | return Math.ceil(x/nearest)*nearest; 110 | } 111 | 112 | let maxBytes = (parseInt(getPrefValue('max_doc_size')) * MEG); 113 | maxBytes = maxBytes < (5 * MEG) ? (5 * MEG) : maxBytes; 114 | 115 | function increaseSizePrompt(requestedSize, currentMaxBytes) { 116 | let currentmax_MB = Math.ceil(currentMaxBytes / MEG); 117 | let requested_MB = Math.ceil(requestedSize / MEG); 118 | let reasonable_MB = ceilToNearest(requested_MB * 1.1, 5); 119 | let message = `Do you want to allow this document to take ${reasonable_MB}MB of space?` 120 | let detail = `The document is requesting ${requested_MB}MB which exceeds the current limit of ${currentmax_MB}MB (set in Preferences).`; 121 | return new Promise((resolve, reject) => { 122 | remote.dialog.showMessageBox(remote.getCurrentWindow(), { 123 | type: 'question', 124 | message: message, 125 | detail: detail, 126 | buttons: [ 127 | "No", 128 | `Allow ${reasonable_MB}MB for now`, 129 | ], 130 | defaultId: 0, 131 | cancelId: 0, 132 | }, response => { 133 | if (response === 1) { 134 | // Allow size increase temporarily 135 | maxBytes = reasonable_MB * MEG; 136 | } 137 | resolve(maxBytes); 138 | }) 139 | }); 140 | } 141 | 142 | window.addEventListener('load', () => { 143 | RPC.call('get_chrootfs_root') 144 | .then(chrootfs_root => { 145 | chfs = new ChrootFS(chrootfs_root, { 146 | maxBytes: maxBytes, 147 | increaseSizePrompt: increaseSizePrompt, 148 | }, rpc_lock); 149 | _.each(fs_attrs, attr => { 150 | LHTML.fs[attr] = (...args) => { 151 | return chfs[attr](...args); 152 | } 153 | // Give an answer to all the pended ones 154 | _.each(pending[attr], pended => { 155 | let {args, resolve, reject} = pended; 156 | try { 157 | resolve(chfs[attr](...args)) 158 | } catch(err) { 159 | reject(err); 160 | } 161 | }) 162 | }) 163 | delete pending; 164 | }); 165 | }) 166 | 167 | //--------------------------- 168 | // Saving stuff 169 | //--------------------------- 170 | 171 | //--------------------------- 172 | // Saving stuff 173 | //--------------------------- 174 | LHTML.saving = {}; 175 | // 176 | // Perform any writing that needs to happen before saving. 177 | // 178 | LHTML.saving.onBeforeSave = () => { 179 | // Thanks http://stackoverflow.com/questions/6088972/get-doctype-of-an-html-as-string-with-javascript/10162353#10162353 180 | let doctype = ''; 181 | let node = document.doctype; 182 | if (node) { 183 | doctype = "'; 189 | } 190 | return LHTML.fs.writeFile('/index.html', 191 | doctype + document.documentElement.outerHTML) 192 | } 193 | // 194 | // Save the current file. 195 | // 196 | LHTML.saving.save = () => { 197 | return RPC.call('save'); 198 | } 199 | let DOC_EDITED = false; 200 | let TMP_DOC_EDITED = false; 201 | let SAVING = false; 202 | LHTML.saving.setDocumentEdited = (edited) => { 203 | edited = !!edited; 204 | if (SAVING) { 205 | // A save has been started but not yet finished 206 | if (edited) { 207 | // An edit has happened since saving 208 | TMP_DOC_EDITED = true; 209 | } else { 210 | // There are no edits since saving. 211 | TMP_DOC_EDITED = false; 212 | } 213 | } else { 214 | if (DOC_EDITED !== edited) { 215 | DOC_EDITED = edited; 216 | return RPC.call('set_document_edited', !!edited); 217 | } else { 218 | // Nothing has changed since the last time you called. 219 | return Promise.resolve(edited); 220 | } 221 | } 222 | } 223 | LHTML.on('saved', () => { 224 | SAVING = false; 225 | // Whatever the edited status was set to during saving is the 226 | // status it is now. 227 | DOC_EDITED = TMP_DOC_EDITED; 228 | RPC.call('set_document_edited', DOC_EDITED); 229 | }) 230 | LHTML.on('save-failed', () => { 231 | SAVING = false; 232 | // If the document was edited before saving OR if it was edited 233 | // during saving, the current status is true (the doc is edited) 234 | DOC_EDITED = DOC_EDITED || TMP_DOC_EDITED; 235 | RPC.call('set_document_edited', DOC_EDITED); 236 | }) 237 | 238 | // 239 | // export a file outside the document 240 | // 241 | LHTML.saving.exportFile = (filename, content, encoding) => { 242 | let ev = window.event; 243 | return new Promise((resolve, reject) => { 244 | if (!ev || ev.type !== 'click') { 245 | // For (over)protection, at this point, downloads can 246 | // only be initiated within an event. 247 | throw new Error("exportFile may only be called in response to clicks"); 248 | } 249 | safe_join(remote.app.getPath('downloads'), filename) 250 | .then(path => { 251 | return new Promise((resolve, reject) => { 252 | remote.dialog.showSaveDialog(remote.getCurrentWindow(), { 253 | defaultPath: path, 254 | }, filename => { 255 | if (filename) { 256 | resolve(filename); 257 | } else { 258 | reject(null); 259 | } 260 | }); 261 | }) 262 | }) 263 | .then(filename => { 264 | // actually save the file 265 | return fs.writeFileAsync(filename, content, {encoding}) 266 | .then(() => { 267 | return filename; 268 | }); 269 | }) 270 | .then(filename => { 271 | // copy extended attribute over 272 | return RPC.call('get_document_path') 273 | .then(source_file => { 274 | return copy_xattr(source_file, filename) 275 | .then(() => { 276 | return filename; 277 | }); 278 | }); 279 | }) 280 | .then(filename => { 281 | resolve({filename}); 282 | }, err => { 283 | reject(err); 284 | }) 285 | }) 286 | } 287 | 288 | // 289 | // form-sync default 290 | // 291 | let form_sync_enabled = true; 292 | LHTML.saving.disableFormSync = () => { 293 | form_sync_enabled = false; 294 | formsync.disable(); 295 | } 296 | window.addEventListener('load', ev => { 297 | if (form_sync_enabled) { 298 | formsync.enable(); 299 | formsync.onChange((element, value) => { 300 | LHTML.saving.setDocumentEdited(true); 301 | }) 302 | } 303 | }); 304 | 305 | // 306 | // Suggest that the document be of a certain size (in pixels) 307 | // 308 | LHTML.suggestSize = (width, height) => { 309 | return RPC.call('suggest_size', { 310 | width: width, 311 | height: height, 312 | }); 313 | } 314 | 315 | window.LHTML = LHTML; 316 | console.log('[LHTML] loaded'); 317 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and 10 | distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by the copyright 13 | owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all other entities 16 | that control, are controlled by, or are under common control with that entity. 17 | For the purposes of this definition, "control" means (i) the power, direct or 18 | indirect, to cause the direction or management of such entity, whether by 19 | contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the 20 | outstanding shares, or (iii) beneficial ownership of such entity. 21 | 22 | "You" (or "Your") shall mean an individual or Legal Entity exercising 23 | permissions granted by this License. 24 | 25 | "Source" form shall mean the preferred form for making modifications, including 26 | but not limited to software source code, documentation source, and configuration 27 | files. 28 | 29 | "Object" form shall mean any form resulting from mechanical transformation or 30 | translation of a Source form, including but not limited to compiled object code, 31 | generated documentation, and conversions to other media types. 32 | 33 | "Work" shall mean the work of authorship, whether in Source or Object form, made 34 | available under the License, as indicated by a copyright notice that is included 35 | in or attached to the work (an example is provided in the Appendix below). 36 | 37 | "Derivative Works" shall mean any work, whether in Source or Object form, that 38 | is based on (or derived from) the Work and for which the editorial revisions, 39 | annotations, elaborations, or other modifications represent, as a whole, an 40 | original work of authorship. For the purposes of this License, Derivative Works 41 | shall not include works that remain separable from, or merely link (or bind by 42 | name) to the interfaces of, the Work and Derivative Works thereof. 43 | 44 | "Contribution" shall mean any work of authorship, including the original version 45 | of the Work and any modifications or additions to that Work or Derivative Works 46 | thereof, that is intentionally submitted to Licensor for inclusion in the Work 47 | by the copyright owner or by an individual or Legal Entity authorized to submit 48 | on behalf of the copyright owner. For the purposes of this definition, 49 | "submitted" means any form of electronic, verbal, or written communication sent 50 | to the Licensor or its representatives, including but not limited to 51 | communication on electronic mailing lists, source code control systems, and 52 | issue tracking systems that are managed by, or on behalf of, the Licensor for 53 | the purpose of discussing and improving the Work, but excluding communication 54 | that is conspicuously marked or otherwise designated in writing by the copyright 55 | owner as "Not a Contribution." 56 | 57 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf 58 | of whom a Contribution has been received by Licensor and subsequently 59 | incorporated within the Work. 60 | 61 | 2. Grant of Copyright License. 62 | 63 | Subject to the terms and conditions of this License, each Contributor hereby 64 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 65 | irrevocable copyright license to reproduce, prepare Derivative Works of, 66 | publicly display, publicly perform, sublicense, and distribute the Work and such 67 | Derivative Works in Source or Object form. 68 | 69 | 3. Grant of Patent License. 70 | 71 | Subject to the terms and conditions of this License, each Contributor hereby 72 | grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, 73 | irrevocable (except as stated in this section) patent license to make, have 74 | made, use, offer to sell, sell, import, and otherwise transfer the Work, where 75 | such license applies only to those patent claims licensable by such Contributor 76 | that are necessarily infringed by their Contribution(s) alone or by combination 77 | of their Contribution(s) with the Work to which such Contribution(s) was 78 | submitted. If You institute patent litigation against any entity (including a 79 | cross-claim or counterclaim in a lawsuit) alleging that the Work or a 80 | Contribution incorporated within the Work constitutes direct or contributory 81 | patent infringement, then any patent licenses granted to You under this License 82 | for that Work shall terminate as of the date such litigation is filed. 83 | 84 | 4. Redistribution. 85 | 86 | You may reproduce and distribute copies of the Work or Derivative Works thereof 87 | in any medium, with or without modifications, and in Source or Object form, 88 | provided that You meet the following conditions: 89 | 90 | You must give any other recipients of the Work or Derivative Works a copy of 91 | this License; and 92 | You must cause any modified files to carry prominent notices stating that You 93 | changed the files; and 94 | You must retain, in the Source form of any Derivative Works that You distribute, 95 | all copyright, patent, trademark, and attribution notices from the Source form 96 | of the Work, excluding those notices that do not pertain to any part of the 97 | Derivative Works; and 98 | If the Work includes a "NOTICE" text file as part of its distribution, then any 99 | Derivative Works that You distribute must include a readable copy of the 100 | attribution notices contained within such NOTICE file, excluding those notices 101 | that do not pertain to any part of the Derivative Works, in at least one of the 102 | following places: within a NOTICE text file distributed as part of the 103 | Derivative Works; within the Source form or documentation, if provided along 104 | with the Derivative Works; or, within a display generated by the Derivative 105 | Works, if and wherever such third-party notices normally appear. The contents of 106 | the NOTICE file are for informational purposes only and do not modify the 107 | License. You may add Your own attribution notices within Derivative Works that 108 | You distribute, alongside or as an addendum to the NOTICE text from the Work, 109 | provided that such additional attribution notices cannot be construed as 110 | modifying the License. 111 | You may add Your own copyright statement to Your modifications and may provide 112 | additional or different license terms and conditions for use, reproduction, or 113 | distribution of Your modifications, or for any such Derivative Works as a whole, 114 | provided Your use, reproduction, and distribution of the Work otherwise complies 115 | with the conditions stated in this License. 116 | 117 | 5. Submission of Contributions. 118 | 119 | Unless You explicitly state otherwise, any Contribution intentionally submitted 120 | for inclusion in the Work by You to the Licensor shall be under the terms and 121 | conditions of this License, without any additional terms or conditions. 122 | Notwithstanding the above, nothing herein shall supersede or modify the terms of 123 | any separate license agreement you may have executed with Licensor regarding 124 | such Contributions. 125 | 126 | 6. Trademarks. 127 | 128 | This License does not grant permission to use the trade names, trademarks, 129 | service marks, or product names of the Licensor, except as required for 130 | reasonable and customary use in describing the origin of the Work and 131 | reproducing the content of the NOTICE file. 132 | 133 | 7. Disclaimer of Warranty. 134 | 135 | Unless required by applicable law or agreed to in writing, Licensor provides the 136 | Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, 137 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, 138 | including, without limitation, any warranties or conditions of TITLE, 139 | NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are 140 | solely responsible for determining the appropriateness of using or 141 | redistributing the Work and assume any risks associated with Your exercise of 142 | permissions under this License. 143 | 144 | 8. Limitation of Liability. 145 | 146 | In no event and under no legal theory, whether in tort (including negligence), 147 | contract, or otherwise, unless required by applicable law (such as deliberate 148 | and grossly negligent acts) or agreed to in writing, shall any Contributor be 149 | liable to You for damages, including any direct, indirect, special, incidental, 150 | or consequential damages of any character arising as a result of this License or 151 | out of the use or inability to use the Work (including but not limited to 152 | damages for loss of goodwill, work stoppage, computer failure or malfunction, or 153 | any and all other commercial damages or losses), even if such Contributor has 154 | been advised of the possibility of such damages. 155 | 156 | 9. Accepting Warranty or Additional Liability. 157 | 158 | While redistributing the Work or Derivative Works thereof, You may choose to 159 | offer, and charge a fee for, acceptance of support, warranty, indemnity, or 160 | other liability obligations and/or rights consistent with this License. However, 161 | in accepting such obligations, You may act only on Your own behalf and on Your 162 | sole responsibility, not on behalf of any other Contributor, and only if You 163 | agree to indemnify, defend, and hold each Contributor harmless for any liability 164 | incurred by, or claims asserted against, such Contributor by reason of your 165 | accepting any such warranty or additional liability. 166 | 167 | END OF TERMS AND CONDITIONS 168 | 169 | APPENDIX: How to apply the Apache License to your work 170 | 171 | To apply the Apache License to your work, attach the following boilerplate 172 | notice, with the fields enclosed by brackets "[]" replaced with your own 173 | identifying information. (Don't include the brackets!) The text should be 174 | enclosed in the appropriate comment syntax for the file format. We also 175 | recommend that a file or class name and description of purpose be included on 176 | the same "printed page" as the copyright notice for easier identification within 177 | third-party archives. 178 | 179 | Copyright 2017 The LHTML team 180 | 181 | Licensed under the Apache License, Version 2.0 (the "License"); 182 | you may not use this file except in compliance with the License. 183 | You may obtain a copy of the License at 184 | 185 | http://www.apache.org/licenses/LICENSE-2.0 186 | 187 | Unless required by applicable law or agreed to in writing, software 188 | distributed under the License is distributed on an "AS IS" BASIS, 189 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 190 | See the License for the specific language governing permissions and 191 | limitations under the License. 192 | -------------------------------------------------------------------------------- /src/chrootfs.js: -------------------------------------------------------------------------------- 1 | var Promise = require("bluebird"); 2 | var fs = require('fs-extra'); 3 | Promise.promisifyAll(fs); 4 | var Path = require('path'); 5 | const klaw = require('klaw'); 6 | const _ = require('lodash'); 7 | const electron_is = require('electron-is'); 8 | 9 | class CBPromise { 10 | constructor() { 11 | this.promise = new Promise((resolve, reject) => { 12 | this.resolve = resolve; 13 | this.reject = reject; 14 | }); 15 | } 16 | } 17 | 18 | class UnsafePath extends Error {} 19 | class TooBigError extends Error {} 20 | 21 | // Get the current size being taken by a directory. 22 | function getDirSize(path) { 23 | // Adapted from http://stackoverflow.com/questions/7529228/how-to-get-totalsize-of-files-in-directory 24 | return new Promise((resolve, reject) => { 25 | fs.lstat(path, (err, stats) => { 26 | if (err) { 27 | reject(err) 28 | } else if (stats.isDirectory()) { 29 | let total = stats.size; 30 | fs.readdir(path, (err, list) => { 31 | if (err) { 32 | reject(err); 33 | } else { 34 | Promise.all(_.map(list, subdir => { 35 | return getDirSize(Path.join(path, subdir)); 36 | })) 37 | .then(sizes => { 38 | resolve(_.sum(sizes) + total); 39 | }) 40 | .catch(err => { 41 | reject(err); 42 | }) 43 | } 44 | }); 45 | } else { 46 | resolve(stats.size); 47 | } 48 | }) 49 | }) 50 | } 51 | 52 | class FakeLock { 53 | run(func) { 54 | return new Promise((resolve, reject) => { 55 | try { 56 | return resolve(func()) 57 | } catch(err) { 58 | reject(err); 59 | } 60 | }) 61 | } 62 | } 63 | 64 | 65 | class ChrootFS { 66 | // 67 | // lock - if given, a class with a `run(func)` method that requests 68 | // a file access lock for reading/writing 69 | // options.increaseSizePrompt - a function that will be called 70 | // if a write operation would exceed the maxBytes allowed 71 | // for this document. Spec is: 72 | // (requestedMaxBytes, currentMaxBytes) => { return newMaxBytes }; 73 | // to deny the request, return currentMaxBytes 74 | // to allow the request, return requestedMaxBytes (or higher) 75 | constructor(path, options, lock) { 76 | options = options || {}; 77 | this._root = null; 78 | this._tmp_root = Path.resolve(path); 79 | this.maxBytes = options.maxBytes || (10 * 2 ** 20); 80 | this.increaseSizePrompt = options.increaseSizePrompt || (() => { return this.maxBytes; }); 81 | this.lock = lock || (new FakeLock()); 82 | } 83 | setRoot(path) { 84 | this._root = null; 85 | this._tmp_root = path; 86 | } 87 | _getRoot() { 88 | if (this._root) { 89 | return Promise.resolve(this._root); 90 | } else { 91 | return new Promise((resolve, reject) => { 92 | fs.realpath(this._tmp_root, (err, resolvedPath) => { 93 | if (err) { 94 | reject(err); 95 | } else { 96 | this._root = resolvedPath; 97 | resolve(this._root); 98 | } 99 | }) 100 | }) 101 | } 102 | } 103 | _getPath(relpath) { 104 | // Turn a path relative to the root of the chroot 105 | // into an absolute-to-the-filesystem path. 106 | // Throws a UnsafePath if the path is outside the chroot. 107 | return this._getRoot() 108 | .then(root => { 109 | return safe_join(root, relpath); 110 | }); 111 | } 112 | writeFile(path, data, ...args) { 113 | return this._getPath(path) 114 | .then(abspath => { 115 | // XXX check that the amount of data being written is okay. 116 | // For now, we're going to stat every file every time, but in 117 | // the future, perhaps we could cache some information. 118 | return getDirSize(this._root) 119 | .then(size => { 120 | let projected_size = size + data.length; 121 | if (projected_size > this.maxBytes) { 122 | return Promise.resolve(this.increaseSizePrompt(projected_size, this.maxBytes)) 123 | .then(newMaxBytes => { 124 | if (newMaxBytes >= projected_size) { 125 | this.maxBytes = newMaxBytes; 126 | return abspath; 127 | } else { 128 | throw new TooBigError("This operation will exceed the max size of " + this.maxBytes); 129 | } 130 | }) 131 | } else { 132 | return abspath; 133 | } 134 | }) 135 | }) 136 | .then(abspath => { 137 | return this.lock.run(() => { 138 | return new Promise((resolve, reject) => { 139 | fs.ensureDir(Path.dirname(abspath), (err) => { 140 | resolve(err) 141 | }) 142 | }) 143 | .then(() => { 144 | return fs.writeFileAsync(abspath, data, ...args); 145 | }); 146 | }) 147 | }); 148 | } 149 | readFile(path, ...args) { 150 | return this._getPath(path) 151 | .then(abspath => { 152 | return this.lock.run(() => { 153 | return fs.readFileAsync(abspath, ...args); 154 | }) 155 | }) 156 | } 157 | listdir(path, options) { 158 | if (_.isObject(path)) { 159 | options = path; 160 | path = undefined; 161 | } 162 | path = path || '/'; 163 | options = options || {}; 164 | let recursive = _.isNil(options.recursive) ? true : options.recursive; 165 | function listdirItem(root, path, stats) { 166 | let relpath = Path.relative(root, path); 167 | let dirname = Path.dirname(relpath); 168 | if (dirname === '.') { 169 | dirname = ''; 170 | } 171 | let ret = { 172 | name: Path.basename(relpath), 173 | path: relpath, 174 | dir: dirname, 175 | size: stats.size, 176 | } 177 | if (stats.isDirectory()) { 178 | ret.isdir = true; 179 | } 180 | return ret; 181 | } 182 | return this._getPath(path) 183 | .then(abspath => { 184 | if (recursive) { 185 | return new Promise((resolve, reject) => { 186 | let items = []; 187 | klaw(abspath) 188 | .on('readable', function() { 189 | let item; 190 | while (item = this.read()) { 191 | let path = Path.relative(abspath, item.path); 192 | if (path === '') { 193 | // root 194 | continue 195 | } 196 | items.push(listdirItem(abspath, item.path, item.stats)); 197 | } 198 | }) 199 | .on('error', (err, item) => { 200 | console.error('error', err, item); 201 | }) 202 | .on('end', () => { 203 | resolve(items); 204 | }) 205 | }); 206 | } else { 207 | // not recursive 208 | return fs.readdirAsync(abspath) 209 | .then(contents => { 210 | return _.map(contents, basename => { 211 | let path = Path.join(abspath, basename) 212 | let ret = listdirItem(abspath, 213 | path, 214 | fs.lstatSync(path)); 215 | return ret; 216 | }) 217 | }) 218 | } 219 | }) 220 | } 221 | remove(path) { 222 | return this._getPath(path) 223 | .then(abspath => { 224 | return this.lock.run(() => { 225 | return new Promise((resolve, reject) => { 226 | fs.remove(abspath, (err) => { 227 | if (err) { 228 | reject(err) 229 | } else { 230 | resolve(null); 231 | } 232 | }); 233 | }); 234 | }) 235 | }) 236 | } 237 | } 238 | 239 | function is_string_path_within(root, child) { 240 | // Return true if absolute child path string is a child of absolute root path string 241 | // This just does string comparison. 242 | // You are expected to make root and child absolute. 243 | let relative = Path.relative(root, child); 244 | if (relative.startsWith('..')) { 245 | return false; 246 | } else { 247 | return true; 248 | } 249 | } 250 | 251 | function resolve_path(x) { 252 | return new Promise((resolve, reject) => { 253 | fs.realpath(x, (err, resolvedPath) => { 254 | if (err) { 255 | if (err.code === "ENOENT") { 256 | // file does not exist 257 | resolve({ 258 | exists: false, 259 | path: x, 260 | orig: x, 261 | }); 262 | } else { 263 | // some other error 264 | reject(err); 265 | } 266 | } else { 267 | // file exists 268 | resolve({ 269 | exists: true, 270 | path: resolvedPath, 271 | orig: x, 272 | }); 273 | } 274 | }) 275 | }); 276 | } 277 | 278 | 279 | function safe_join() { 280 | let base = Path.normalize(arguments[0]); 281 | let rest = [].slice.call(arguments).slice(1); 282 | 283 | // Resolve the relative path 284 | rest = Path.normalize(Path.join(...rest)); 285 | if (Path.isAbsolute(rest)) { 286 | rest = Path.relative('/', rest); 287 | } 288 | let alleged_path = Path.resolve(base, rest); 289 | 290 | let resolved_path = resolve_path(alleged_path); 291 | let resolved_base = resolve_path(base); 292 | 293 | return Promise.all([resolved_base, resolved_path]) 294 | .then(result => { 295 | let r_base = result[0]; 296 | let r_path = result[1]; 297 | if (r_base.exists) { 298 | if (r_path.exists) { 299 | if (is_string_path_within(r_base.path, r_path.path)) { 300 | return r_path.orig; 301 | } 302 | } else { 303 | if (is_string_path_within(r_base.orig, r_path.path) 304 | || is_string_path_within(r_base.path, r_path.path)) { 305 | return r_path.path; 306 | } 307 | } 308 | } else { 309 | // root doesn't exist 310 | if (is_string_path_within(r_base.path, r_path.path)) { 311 | return r_path.path; 312 | } 313 | } 314 | throw new UnsafePath(r_path.orig + ' is outside base dir.'); 315 | }); 316 | } 317 | 318 | // 319 | // Copy extended attributes from src to dst 320 | // 321 | function copy_xattr(src, dst) { 322 | return new Promise((resolve, reject) => { 323 | if (electron_is.macOS()) { 324 | const xattr = require('fs-xattr'); 325 | xattr.list(src, (err, list) => { 326 | if (err) { 327 | throw err; 328 | } 329 | let promises = _.map(list, key => { 330 | return new Promise((inner_resolve, inner_reject) => { 331 | xattr.get(src, key, (err, val) => { 332 | if (err) { 333 | throw err; 334 | } 335 | xattr.set(dst, key, val, (err) => { 336 | if (err) { 337 | throw err; 338 | } 339 | inner_resolve({key, value:val}); 340 | }) 341 | }) 342 | }) 343 | }); 344 | Promise.all(promises) 345 | .then(resolve, reject); 346 | }); 347 | } else if (electron_is.windows()) { 348 | // XXX this is untested 349 | fs.readFileAsync(src + ':Zone.Identifier') 350 | .then(content => { 351 | return fs.writeFileAsync(dst + ':Zone.Identifier', content) 352 | }) 353 | .then(result => { 354 | resolve(result); 355 | }) 356 | .catch(err => { 357 | reject(err); 358 | }) 359 | } else { 360 | // No support for extended attributes 361 | } 362 | }) 363 | } 364 | 365 | 366 | module.exports = {ChrootFS, TooBigError, safe_join, copy_xattr}; 367 | -------------------------------------------------------------------------------- /test/test_chrootfs.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'); 2 | var fs = require('fs-extra'); 3 | var Path = require('path'); 4 | const _ = require('lodash'); 5 | const Tmp = require('tmp'); 6 | const {ChrootFS, safe_join, TooBigError} = require('../src/chrootfs.js'); 7 | 8 | 9 | describe('ChrootFS', function() { 10 | let tmpdir; 11 | 12 | beforeEach(() => { 13 | tmpdir = Tmp.dirSync().name; 14 | }); 15 | 16 | afterEach(() => { 17 | return fs.remove(tmpdir) 18 | }); 19 | 20 | describe('.writeFile', function() { 21 | it('lets you read/write files within the chroot dir', () => { 22 | let chfs = new ChrootFS(tmpdir); 23 | return chfs.writeFile('hello', 'some contents') 24 | .then(result => { 25 | return chfs.readFile('hello', 'utf8'); 26 | }) 27 | .then(contents => { 28 | assert.equal(contents, 'some contents'); 29 | assert.equal(typeof contents, 'string'); 30 | }); 31 | }); 32 | 33 | it("Makes directories when you write to a subdirectory", () => { 34 | let chfs = new ChrootFS(tmpdir); 35 | return chfs.writeFile('a/b/c/hello', 'goober') 36 | .then(result => { 37 | return chfs.readFile('a/b/c/hello', 'utf8'); 38 | }) 39 | .then(contents => { 40 | assert.equal(contents, 'goober'); 41 | }) 42 | }) 43 | 44 | describe('caps space', function() { 45 | it('with no other files', () => { 46 | let chfs = new ChrootFS(tmpdir, { 47 | maxBytes: 1000, 48 | }); 49 | let contents = 'A'.repeat(1001); 50 | return chfs.writeFile('hello', contents) 51 | .then(() => {}, () => {}) 52 | .then(() => { 53 | return chfs.readFile('hello'); 54 | }) 55 | .then(contents => { 56 | assert.equal(true, false, "Should not have returned contents"); 57 | }, err => { 58 | assert.equal(true, true, "Should not have made the file"); 59 | }); 60 | }); 61 | 62 | it('with prompting', () => { 63 | let chfs = new ChrootFS(tmpdir, { 64 | maxBytes: 500, 65 | increaseSizePrompt: (() => { return 1500; }), 66 | }); 67 | let contents = 'A'.repeat(1000); 68 | return chfs.writeFile('hello', contents) 69 | .then(() => {}, () => {}) 70 | .then(() => { 71 | return chfs.readFile('hello'); 72 | }) 73 | }); 74 | 75 | it('with subdirectories full of files', () => { 76 | let chfs = new ChrootFS(tmpdir, { 77 | maxBytes: 1000, 78 | }); 79 | let contents = 'A'.repeat(501); 80 | 81 | return chfs.writeFile('something/here', contents) 82 | .then(() => { 83 | return chfs.writeFile('hello', contents) 84 | }) 85 | .then(() => {}, () => {}) 86 | .then(() => { 87 | return chfs.readFile('hello'); 88 | }) 89 | .then(contents => { 90 | assert.equal(true, false, "Should not have returned contents"); 91 | }, err => { 92 | assert.equal(true, true, "Should not have made the file"); 93 | }); 94 | }) 95 | }); 96 | 97 | it("write can't break out of chroot with ..", () => { 98 | let chfs = new ChrootFS(tmpdir); 99 | return chfs.writeFile('../something', 'hello') 100 | .then(() => { 101 | assert.equal(true, false, "Should not have succeeded"); 102 | }) 103 | .catch(err => { 104 | assert.equal(true, true); 105 | }); 106 | }); 107 | 108 | it("write can't break out of chroot with /", () => { 109 | let chfs = new ChrootFS(tmpdir); 110 | return chfs.writeFile('/tmp/foo', 'hello') 111 | .then(() => { 112 | assert.equal(true, false, "Should not have succeeded"); 113 | }) 114 | .catch(err => { 115 | assert.equal(true, true); 116 | }); 117 | }); 118 | }); 119 | 120 | describe('.listdir', function() { 121 | beforeEach(function() { 122 | fs.writeFileSync(Path.join(tmpdir, 'a.txt'), 'a contents'); 123 | fs.writeFileSync(Path.join(tmpdir, 'b.txt'), 'b contents'); 124 | fs.ensureDirSync(Path.join(tmpdir, 'sub')) 125 | fs.writeFileSync(Path.join(tmpdir, 'sub/c.txt'), 'c contents'); 126 | fs.writeFileSync(Path.join(tmpdir, 'sub/d.txt'), 'd contents'); 127 | }) 128 | it('lists recursively by default', () => { 129 | let chfs = new ChrootFS(tmpdir); 130 | return chfs.listdir() 131 | .then(contents => { 132 | assert.equal(contents.length, 5); 133 | _.each(contents, file => { 134 | if (file.name === 'a.txt') { 135 | assert.equal(file.path, 'a.txt'); 136 | assert.equal(file.dir, ''); 137 | assert.equal(file.size, 'a contents'.length); 138 | } else if (file.name === 'b.txt') { 139 | assert.equal(file.path, 'b.txt'); 140 | assert.equal(file.dir, ''); 141 | assert.equal(file.size, 'b contents'.length); 142 | } else if (file.name === 'sub') { 143 | assert.equal(file.path, 'sub'); 144 | assert.equal(file.dir, ''); 145 | assert.equal(file.isdir, true); 146 | } else if (file.name === 'c.txt') { 147 | assert.equal(file.path, 'sub/c.txt'); 148 | assert.equal(file.dir, 'sub'); 149 | assert.equal(file.size, 'c contents'.length); 150 | } else if (file.name === 'd.txt') { 151 | assert.equal(file.path, 'sub/d.txt'); 152 | assert.equal(file.dir, 'sub'); 153 | assert.equal(file.size, 'd contents'.length); 154 | } else { 155 | assert.equal(true, false, "Unexpected file: " + file.name); 156 | } 157 | }) 158 | }); 159 | }) 160 | 161 | it('can list a subdir', () => { 162 | let chfs = new ChrootFS(tmpdir); 163 | return chfs.listdir('sub') 164 | .then(contents => { 165 | assert.equal(contents.length, 2) 166 | _.each(contents, file => { 167 | if (file.name === 'c.txt') { 168 | assert.equal(file.path, 'c.txt') 169 | assert.equal(file.dir, ''); 170 | } else if (file.name === 'd.txt') { 171 | assert.equal(file.path, 'd.txt') 172 | assert.equal(file.dir, ''); 173 | } else { 174 | assert.equal(true, false, "Unexpected file: " + file.name); 175 | } 176 | }) 177 | }) 178 | }) 179 | 180 | it('can run non-recursively', () => { 181 | let chfs = new ChrootFS(tmpdir); 182 | return chfs.listdir({recursive:false}) 183 | .then(contents => { 184 | assert.equal(contents.length, 3); 185 | _.each(contents, file => { 186 | if (file.name === 'a.txt') { 187 | assert.equal(file.path, 'a.txt'); 188 | assert.equal(file.dir, ''); 189 | assert.equal(file.size, 'a contents'.length); 190 | } else if (file.name === 'b.txt') { 191 | assert.equal(file.path, 'b.txt'); 192 | assert.equal(file.dir, ''); 193 | assert.equal(file.size, 'b contents'.length); 194 | } else if (file.name === 'sub') { 195 | assert.equal(file.path, 'sub'); 196 | assert.equal(file.dir, ''); 197 | assert.equal(file.isdir, true); 198 | } else { 199 | assert.equal(true, false, "Unexpected file: " + file.name); 200 | } 201 | }) 202 | }) 203 | }) 204 | 205 | it('can run non-recursively on subdir', () => { 206 | let chfs = new ChrootFS(tmpdir); 207 | return chfs.listdir('sub', {recursive:false}) 208 | .then(contents => { 209 | assert.equal(contents.length, 2); 210 | _.each(contents, file => { 211 | if (file.name === 'c.txt') { 212 | assert.equal(file.path, 'c.txt') 213 | assert.equal(file.dir, ''); 214 | } else if (file.name === 'd.txt') { 215 | assert.equal(file.path, 'd.txt') 216 | assert.equal(file.dir, ''); 217 | } else { 218 | assert.equal(true, false, "Unexpected file: " + file.name); 219 | } 220 | }) 221 | }) 222 | }) 223 | }); 224 | 225 | describe('.remove', function() { 226 | it('deletes files', () => { 227 | let chfs = new ChrootFS(tmpdir); 228 | fs.writeFileSync(Path.join(tmpdir, 'a.txt'), 'a contents'); 229 | return chfs.remove('a.txt') 230 | .then(() => { 231 | return chfs.listdir(); 232 | }) 233 | .then(contents => { 234 | assert.equal(contents.length, 0); 235 | }); 236 | }); 237 | it('deletes directories', () => { 238 | let chfs = new ChrootFS(tmpdir); 239 | fs.ensureDirSync(Path.join(tmpdir, 'subdir')); 240 | return chfs.remove('subdir') 241 | .then(() => { 242 | return chfs.listdir(); 243 | }) 244 | .then(contents => { 245 | assert.equal(contents.length, 0); 246 | }); 247 | }); 248 | it('deletes full directories', () => { 249 | let chfs = new ChrootFS(tmpdir); 250 | fs.ensureDirSync(Path.join(tmpdir, 'subdir')); 251 | fs.writeFileSync(Path.join(tmpdir, 'subdir/a.txt'), 'a contents'); 252 | return chfs.remove('subdir') 253 | .then(() => { 254 | return chfs.listdir(); 255 | }) 256 | .then(contents => { 257 | assert.equal(contents.length, 0); 258 | }); 259 | }); 260 | }) 261 | }); 262 | 263 | 264 | describe('safe_join', function() { 265 | it("/ as child", () => { 266 | return safe_join('/Users/matt/a/b/c', '/js/lodash.min.js') 267 | .then(result => { 268 | assert.equal(result, '/Users/matt/a/b/c/js/lodash.min.js'); 269 | }) 270 | }); 271 | 272 | describe("non-existant paths", function() { 273 | it("Doesn't allow ..", () => { 274 | return safe_join('/foo/bar', '../hey') 275 | .then(() => { 276 | assert.equal(true, false, "Should not succeed"); 277 | }, (err) => { 278 | assert.equal(true, true, "Should have failed"); 279 | }) 280 | }); 281 | it("Allows / to be the root", () => { 282 | return safe_join('/foo/bar', '/hey') 283 | .then((result) => { 284 | assert.equal(result, "/foo/bar/hey"); 285 | }); 286 | }); 287 | it("allows normal paths", () => { 288 | return safe_join('/foo/bar', 'hope') 289 | .then((result) => { 290 | assert.equal(result, "/foo/bar/hope"); 291 | }); 292 | }); 293 | }); 294 | 295 | describe("existing root", function() { 296 | let tmpdir; 297 | let r_tmpdir; 298 | beforeEach(() => { 299 | tmpdir = Tmp.dirSync().name; 300 | return new Promise((resolve, reject) => { 301 | fs.realpath(tmpdir, (err, resolvedPath) => { 302 | r_tmpdir = resolvedPath; 303 | resolve(r_tmpdir); 304 | }); 305 | }) 306 | }); 307 | afterEach(() => { 308 | return fs.remove(tmpdir) 309 | }); 310 | 311 | describe("non-existant children", function() { 312 | it("Doesn't allow ..", () => { 313 | return safe_join(tmpdir, '../hey') 314 | .then(() => { 315 | assert.equal(true, false, "Should not succeed"); 316 | }, (err) => { 317 | assert.equal(true, true, "Should have failed"); 318 | }) 319 | }); 320 | it("Allows / to be the root", () => { 321 | return safe_join(tmpdir, '/hey') 322 | .then((result) => { 323 | assert.equal(result, tmpdir + Path.sep + "hey"); 324 | }); 325 | }); 326 | it("allows normal paths", () => { 327 | return safe_join(tmpdir, 'hope') 328 | .then((result) => { 329 | assert.equal(result, tmpdir + Path.sep + "hope"); 330 | }); 331 | }); 332 | }); 333 | 334 | describe("existing children", function() { 335 | beforeEach(() => { 336 | fs.writeFileSync(Path.join(tmpdir, 'a.txt'), 'a contents'); 337 | }) 338 | it("Allows / to be the root", () => { 339 | return safe_join(tmpdir, '/a.txt') 340 | .then((result) => { 341 | assert.equal(result, tmpdir + Path.sep + "a.txt"); 342 | }); 343 | }); 344 | it("allows normal paths", () => { 345 | return safe_join(tmpdir, 'a.txt') 346 | .then((result) => { 347 | assert.equal(result, tmpdir + Path.sep + "a.txt"); 348 | }); 349 | }); 350 | }); 351 | }); 352 | 353 | }); -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) The LHTML team 2 | // See LICENSE for details. 3 | 4 | const {ipcMain, dialog, app, BrowserWindow, Menu, protocol, webContents, net, shell} = require('electron'); 5 | const electron_is = require('electron-is'); 6 | const {session} = require('electron'); 7 | var electron = require('electron'); 8 | const Path = require('path'); 9 | const fs = require('fs-extra'); 10 | const URL = require('url'); 11 | const {RPCService} = require('./rpc.js'); 12 | const _ = require('lodash'); 13 | const Tmp = require('tmp'); 14 | const AdmZip = require('adm-zip'); 15 | const log = require('electron-log'); 16 | const {safe_join} = require('./chrootfs.js'); 17 | const {autoUpdater} = require("electron-updater"); 18 | const {GroupSemaphore} = require('./locks.js'); 19 | const {showPreferenceWindow, getPrefValue} = require('./prefs/prefs.js'); 20 | 21 | const OPEN_DEVTOOLS = process.env.OPEN_DEVTOOLS ? true : false; 22 | 23 | autoUpdater.logger = log; 24 | log.transports.console.level = log.transports.file.level = process.env.LOGLEVEL || 'debug'; 25 | log.transports.file.maxSize = (5 * 1024 * 1024); 26 | 27 | log.info('LHTML starting...'); 28 | 29 | let template = [{ 30 | label: 'File', 31 | submenu: [ 32 | { 33 | label: 'New From Template...', 34 | accelerator: 'CmdOrCtrl+N', 35 | click() { 36 | return newFromTemplate(); 37 | }, 38 | }, 39 | { 40 | label: 'Open...', 41 | accelerator: 'CmdOrCtrl+O', 42 | click() { 43 | return promptOpenFile(); 44 | }, 45 | }, 46 | { 47 | label: 'Reload', 48 | accelerator: 'CmdOrCtrl+R', 49 | click() { 50 | return reloadFocusedDoc(); 51 | }, 52 | }, 53 | { 54 | label: 'Save', 55 | accelerator: 'CmdOrCtrl+S', 56 | click() { 57 | return saveFocusedDoc(); 58 | }, 59 | doc_only: true, 60 | }, 61 | { 62 | label: 'Save As...', 63 | accelerator: 'CmdOrCtrl+Shift+S', 64 | click() { 65 | return saveAsFocusedDoc(); 66 | }, 67 | doc_only: true, 68 | }, 69 | // Until issue #57 is fixed, don't expose this 70 | // { 71 | // label: 'Save As Template...', 72 | // click() { 73 | // return saveTemplateFocusedDoc(); 74 | // }, 75 | // doc_only: true, 76 | // }, 77 | { 78 | label: 'Close', 79 | accelerator: 'CmdOrCtrl+W', 80 | click() { 81 | return closeFocusedDoc(); 82 | } 83 | }, 84 | { 85 | type: 'separator' 86 | }, 87 | { 88 | label: 'Open Directory...', 89 | accelerator: 'CmdOrCtrl+Shift+O', 90 | click() { 91 | return openDirectory(); 92 | } 93 | }, 94 | { 95 | type: 'separator' 96 | }, 97 | { 98 | label: 'Export As PDF...', 99 | accelerator: 'CmdOrCtrl+P', 100 | doc_only: true, 101 | click() { 102 | return printToPDF(); 103 | } 104 | } 105 | ] 106 | }, 107 | { 108 | label: 'Edit', 109 | submenu: [ 110 | { 111 | role: 'undo' 112 | }, 113 | { 114 | role: 'redo' 115 | }, 116 | { 117 | type: 'separator' 118 | }, 119 | { 120 | role: 'cut' 121 | }, 122 | { 123 | role: 'copy' 124 | }, 125 | { 126 | role: 'paste' 127 | }, 128 | { 129 | role: 'pasteandmatchstyle' 130 | }, 131 | { 132 | role: 'delete' 133 | }, 134 | { 135 | role: 'selectall' 136 | } 137 | ] 138 | }, 139 | { 140 | label: 'View', 141 | submenu: [ 142 | { 143 | label: 'Toggle Dev Tools', 144 | click() { 145 | toggleMainDevTools(); 146 | }, 147 | }, 148 | { 149 | label: 'Toggle Document Dev Tools', 150 | click() { 151 | toggleDocumentDevTools(); 152 | }, 153 | doc_only: true, 154 | }, 155 | ] 156 | }]; 157 | 158 | let help_menu = { 159 | label: 'Help', 160 | submenu: [ 161 | { 162 | label: 'Show Logs', 163 | click() { 164 | showLogFileInFolder(); 165 | } 166 | }, 167 | ], 168 | }; 169 | template.push(help_menu); 170 | 171 | if (process.platform === 'darwin') { 172 | // macOS 173 | const name = 'LHTML'; 174 | template.unshift({ 175 | label: name, 176 | submenu: [ 177 | { 178 | label: 'About ' + name, 179 | click() { 180 | promptForUpdate(); 181 | }, 182 | }, 183 | { 184 | label: 'Check for Updates...', 185 | click() { 186 | promptForUpdate(); 187 | }, 188 | }, 189 | {type: 'separator'}, 190 | { 191 | label: 'Preferences...', 192 | accelerator: 'CmdOrCtrl+,', 193 | click() { 194 | showPreferenceWindow(); 195 | } 196 | }, 197 | {type: 'separator'}, 198 | { 199 | label: 'Services', 200 | role: 'services', 201 | submenu: [], 202 | }, 203 | {type: 'separator'}, 204 | { 205 | label: 'Hide ' + name, 206 | accelerator: 'Command+H', 207 | role: 'hide' 208 | }, 209 | { 210 | label: 'Hide Others', 211 | accelerator: 'Command+Alt+H', 212 | role: 'hideothers' 213 | }, 214 | { 215 | label: 'Show All', 216 | role: 'unhide' 217 | }, 218 | { 219 | type: 'separator' 220 | }, 221 | { 222 | label: 'Quit', 223 | accelerator: 'Command+Q', 224 | click() { app.quit(); } 225 | }, 226 | ] 227 | }) 228 | } else { 229 | // Not macOS 230 | template[1].submenu.push({type: 'separator'}) 231 | template[1].submenu.push({ 232 | label: 'Preferences...', 233 | accelerator: 'CmdOrCtrl+,', 234 | click() { 235 | showPreferenceWindow(); 236 | } 237 | }) 238 | const name = 'LHTML'; 239 | help_menu.submenu.splice(0, 0, 240 | ...[ 241 | { 242 | label: 'About ' + name, 243 | click() { 244 | promptForUpdate(); 245 | } 246 | }, 247 | { 248 | label: 'Check for Updates...', 249 | click() { 250 | promptForUpdate(); 251 | }, 252 | }, 253 | { type: 'separator' }, 254 | ]) 255 | } 256 | 257 | let default_window = null; 258 | 259 | function createDefaultWindow() { 260 | default_window = new BrowserWindow({ 261 | titleBarStyle: 'hidden', 262 | width: 400, 263 | height: 300, 264 | resizable: false, 265 | show: false, 266 | }); 267 | default_window.on('ready-to-show', () => { 268 | default_window.show(); 269 | }) 270 | default_window.on('closed', () => { 271 | default_window = null; 272 | }); 273 | default_window.on('focus', () => { 274 | enableDocMenuItems(false); 275 | }) 276 | default_window.loadURL(`file://${__dirname}/default.html?version=v${app.getVersion()}`); 277 | return default_window; 278 | } 279 | 280 | //------------------------------------------------------------------- 281 | // Auto updates 282 | //------------------------------------------------------------------- 283 | class Updater { 284 | constructor(state_receiver) { 285 | this.state_receiver = state_receiver; 286 | 287 | this.state = { 288 | action: null, 289 | error: null, 290 | latest_version: null, 291 | update_available: null, 292 | update_downloaded: null, 293 | } 294 | 295 | autoUpdater.on('checking-for-update', () => { 296 | this.state.action = 'checking'; 297 | state_receiver(this.state); 298 | }) 299 | autoUpdater.on('update-available', (info) => { 300 | log.info('arguments', arguments); 301 | this.state.action = 'downloading'; 302 | this.state.latest_version = info; 303 | this.state.update_available = true; 304 | state_receiver(this.state); 305 | log.info('update-available info', info); 306 | }) 307 | autoUpdater.on('update-not-available', (info) => { 308 | log.info('arguments', arguments); 309 | this.state.action = null; 310 | this.state.latest_version = info; 311 | this.state.update_available = false; 312 | state_receiver(this.state); 313 | log.info('update-not-available info', info); 314 | }) 315 | autoUpdater.on('error', (ev, err) => { 316 | this.state.error = 'Error encountered while updating.'; 317 | this.state.action = null; 318 | state_receiver(this.state); 319 | }) 320 | autoUpdater.on('download-progress', (info) => { 321 | this.state.action = 'downloading'; 322 | state_receiver(this.state); 323 | }) 324 | autoUpdater.on('update-downloaded', (info) => { 325 | this.state.action = null; 326 | this.state.update_downloaded = true; 327 | state_receiver(this.state); 328 | }) 329 | } 330 | checkForUpdates() { 331 | this.state_receiver(this.state); 332 | if (this.state.action || this.state.update_downloaded 333 | || this.state.latest_versrion || this.state.update_available) { 334 | return; 335 | } 336 | autoUpdater.checkForUpdates(); 337 | } 338 | } 339 | let update_window; 340 | 341 | let updater = new Updater(state => { 342 | if (update_window) { 343 | update_window.webContents.send('state', state); 344 | } 345 | }); 346 | ipcMain.on('do-update', () => { 347 | if (updater.state.update_downloaded) { 348 | autoUpdater.quitAndInstall(); 349 | } 350 | }); 351 | 352 | function promptForUpdate() { 353 | if (update_window) { 354 | // window already exists 355 | updater.checkForUpdates(); 356 | return update_window; 357 | } 358 | update_window = new BrowserWindow({ 359 | width: 300, 360 | height: 250, 361 | resizable: false, 362 | show: false, 363 | title: '', 364 | }); 365 | update_window.on('ready-to-show', () => { 366 | update_window.show(); 367 | updater.checkForUpdates(); 368 | }) 369 | update_window.on('closed', () => { 370 | update_window = null; 371 | }); 372 | update_window.loadURL(`file://${__dirname}/updates.html?version=${app.getVersion()}`); 373 | return update_window; 374 | } 375 | 376 | 377 | function createLHTMLWindow() { 378 | // Create a session with network access disabled 379 | let win = new BrowserWindow({ 380 | width: 800, 381 | height: 600, 382 | minWidth: 320, 383 | minHeight: 240, 384 | show: false, 385 | }); 386 | win.on('ready-to-show', () => { 387 | if (OPEN_DEVTOOLS) { 388 | win.webContents.openDevTools('right'); 389 | } 390 | win.show(); 391 | }) 392 | win.on('resize', () => { 393 | const [width, height] = win.getContentSize(); 394 | for (let wc of webContents.getAllWebContents()) { 395 | if (wc.hostWebContents && wc.hostWebContents.id === win.webContents.id) { 396 | wc.setSize({ 397 | normal: { 398 | width: width, 399 | height: height, 400 | } 401 | }) 402 | } 403 | } 404 | }) 405 | win.loadURL(`file://${__dirname}/lhtml_container.html`); 406 | var win_id = win.id; 407 | win.on('close', (ev) => { 408 | if (win.isDocumentEdited()) { 409 | let choice = dialog.showMessageBox(win, { 410 | type: 'question', 411 | buttons: ['Cancel', 'Close without saving'], 412 | cancelId: 0, 413 | defaultId: 0, 414 | title: 'Unsaved Changes', 415 | message: "Do you want to save the changes you made in this document?", 416 | detail: "Your changes will be lost if you don't save them.", 417 | }); 418 | if (choice === 0) { 419 | ev.preventDefault(); 420 | win.close_promise && win.close_promise(false); 421 | } 422 | } 423 | }) 424 | win.on('closed', () => { 425 | win.close_promise && win.close_promise(true) 426 | let doc = WINDOW2DOC_INFO[win_id]; 427 | if (!doc) { 428 | return; 429 | } 430 | doc.close(); 431 | delete WINDOW2DOC_INFO[win_id]; 432 | delete OPENDOCUMENTS[doc.id]; 433 | }); 434 | win.on('focus', () => { 435 | enableDocMenuItems(true); 436 | }) 437 | 438 | // Close the default window once a guest window has been opened. 439 | if (default_window) { 440 | default_window.close(); 441 | } 442 | return win; 443 | } 444 | 445 | 446 | function randomIdentifier() { 447 | return 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'.replace(/[x]/g, function(c) { 448 | var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8); 449 | return v.toString(16); 450 | }); 451 | }; 452 | 453 | let OPENDOCUMENTS = {}; 454 | let WINDOW2DOC_INFO = {}; 455 | 456 | 457 | class Document { 458 | // 459 | // @param path: Path to file being opened. 460 | constructor(path) { 461 | this.window_id = null; 462 | this.lock = new GroupSemaphore({ 463 | save: 'single', 464 | io: 'multiple', 465 | }); 466 | 467 | // random 468 | this.id = randomIdentifier(); 469 | 470 | // Path to the LHTML file or directory 471 | this.save_path = path; 472 | this.is_directory = fs.lstatSync(path).isDirectory(); 473 | this._tmpdir = null; 474 | 475 | // Path where LHTML is expanded into 476 | this._working_dir = null; 477 | } 478 | get working_dir() { 479 | if (!this._working_dir) { 480 | if (this.is_directory) { 481 | this._working_dir = this.save_path; 482 | } else { 483 | this._tmpdir = Tmp.dirSync({unsafeCleanup: true}); 484 | this._working_dir = this._tmpdir.name; 485 | let zip = new AdmZip(this.save_path); 486 | log.info('extracting to', this._working_dir); 487 | zip.extractAllTo(this._working_dir, /*overwrite*/ true); 488 | } 489 | this.emitWorkingDir(); 490 | } 491 | return this._working_dir; 492 | } 493 | close() { 494 | return new Promise((resolve, reject) => { 495 | if (this._tmpdir) { 496 | try { 497 | this._tmpdir.removeCallback() 498 | } catch(err) { 499 | log.error('Error deleting working_dir:', err); 500 | } 501 | this._tmpdir = null; 502 | this._working_dir = null; 503 | resolve(null); 504 | } 505 | }) 506 | } 507 | _updateWorkingDirFromSaveData() { 508 | let guest = this._rpcGuest(); 509 | return RPC.call('save_your_stuff', null, guest); 510 | } 511 | get window() { 512 | if (_.isNil(this.window_id)) { 513 | return null; 514 | } 515 | return BrowserWindow.fromId(this.window_id); 516 | } 517 | _rpcGuest() { 518 | if (!this.window) { 519 | throw new Error('No window'); 520 | } 521 | return this.window.webContents; 522 | } 523 | emitWorkingDir() { 524 | if (!this.window) { 525 | return; 526 | } 527 | let guest = this._rpcGuest(); 528 | return RPC.call('set_chrootfs_root', this._working_dir, guest); 529 | } 530 | save() { 531 | if (!this.save_path) { 532 | return this.saveAs(); 533 | } 534 | let guest = this._rpcGuest(); 535 | return this._updateWorkingDirFromSaveData() 536 | .then(() => { 537 | return this.lock.run('save', () => { 538 | console.log('this.working_dir', this.working_dir); 539 | console.log('this.save_path', this.save_path); 540 | if (this.is_directory) { 541 | // done, it's already saved 542 | } else { 543 | // Write a new zip file 544 | let zip = new AdmZip(); 545 | zip.addLocalFolder(this.working_dir, '.'); 546 | zip.writeZip(this.save_path); 547 | } 548 | log.info('saved', this.save_path); 549 | RPC.call('emit_event', {'key': 'saved', 'data': null}, guest); 550 | }) 551 | }, err => { 552 | log.error(err); 553 | RPC.call('emit_event', {'key': 'save-failed', 'data': null}, guest); 554 | }) 555 | } 556 | saveAs() { 557 | let defaultPath = this.save_path ? Path.dirname(this.save_path) : null; 558 | return new Promise((resolve, reject) => { 559 | dialog.showSaveDialog({ 560 | defaultPath: defaultPath, 561 | filters: [ 562 | {name: 'LHTML', extensions: ['lhtml']}, 563 | {name: 'All Files', extensions: ['*']}, 564 | ], 565 | }, dst => { 566 | if (!dst) { 567 | return; 568 | } 569 | return this.changeSavePath(dst) 570 | .then(() => { 571 | return this.save(); 572 | }) 573 | .then(result => { 574 | resolve(result); 575 | }) 576 | }); 577 | }) 578 | } 579 | changeSavePath(new_path) { 580 | return this.lock.run('save', () => { 581 | log.debug('changeSavePath', this.save_path, '-->', new_path); 582 | if (new_path !== this.save_path) { 583 | let new_is_directory = fs.existsSync(new_path) && fs.lstatSync(new_path).isDirectory() 584 | if (this.is_directory) { 585 | if (new_is_directory) { 586 | log.debug('dir -> dir'); 587 | 588 | this._working_dir = null; 589 | } else { 590 | log.debug('dir -> file'); 591 | 592 | this._tmpdir = Tmp.dirSync({unsafeCleanup: true}); 593 | this._working_dir = this._tmpdir.name; 594 | fs.copySync(this.save_path, this._working_dir) 595 | } 596 | this.emitWorkingDir(); 597 | } else { 598 | if (new_is_directory) { 599 | log.debug('file -> dir'); 600 | } else { 601 | log.debug('file -> file'); 602 | } 603 | } 604 | this.is_directory = new_is_directory; 605 | this.save_path = new_path; 606 | this.window && this.window.setDocumentEdited(true); 607 | } 608 | }) 609 | } 610 | attachToWindow(window_id) { 611 | if (this.window_id) { 612 | throw new Error('Document already attached to a window'); 613 | } 614 | this.window_id = window_id; 615 | } 616 | 617 | } 618 | 619 | protocol.registerStandardSchemes(['lhtml']) 620 | 621 | let openfirst; 622 | app.on('open-file', function(event, path) { 623 | if (app.isReady()) { 624 | openPath(path); 625 | } else { 626 | openfirst = path; 627 | } 628 | event.preventDefault(); 629 | }) 630 | 631 | let menu; 632 | // 633 | // Go through the menu template and find all the menu item 634 | // labels that have `doc_only` set to `true` 635 | function getDocOnlyMenuItems(templ) { 636 | return _(templ) 637 | .map((item) => { 638 | let ret = []; 639 | if (item.submenu) { 640 | ret.push(getDocOnlyMenuItems(item.submenu)) 641 | } 642 | if (item.doc_only) { 643 | ret.push(item.label) 644 | } 645 | return ret; 646 | }) 647 | .flattenDeep() 648 | .value(); 649 | } 650 | let doc_only_menu_items = getDocOnlyMenuItems(template); 651 | function enableDocMenuItems(enabled, themenu) { 652 | themenu = themenu || menu; 653 | _.each(themenu.items, (item) => { 654 | if (item.submenu) { 655 | enableDocMenuItems(enabled, item.submenu); 656 | } 657 | if (_.includes(doc_only_menu_items, item.label)) { 658 | item.enabled = enabled; 659 | } 660 | }); 661 | } 662 | 663 | app.on('ready', function() { 664 | // Updates 665 | if (process.env.CHECK_FOR_UPDATES === "no" || process.env.RUN_TESTS) { 666 | log.info('UPDATE CHECKING DISABLED'); 667 | } else if (electron_is.dev()) { 668 | log.info('UPDATE CHECKING DISABLED in dev'); 669 | } else { 670 | updater.checkForUpdates(); 671 | } 672 | 673 | let sesh = session.fromPartition('persist:webviews'); 674 | 675 | // Handle lhtml:// 676 | sesh.protocol.registerFileProtocol('lhtml', (request, callback) => { 677 | const parsed = URL.parse(request.url); 678 | const domain = parsed.host; 679 | const path = parsed.path; 680 | const root_dir = OPENDOCUMENTS[domain].working_dir; 681 | safe_join(root_dir, path).then(file_path => { 682 | callback({path: file_path}); 683 | }); 684 | }, (error) => { 685 | if (error) { 686 | throw new Error('failed to register lhtml protocol'); 687 | } 688 | }) 689 | 690 | // Disable networking for webviews 691 | sesh.webRequest.onBeforeRequest((details, callback) => { 692 | if (details.url.startsWith('lhtml://')) { 693 | // lhtml requests are allowed 694 | callback({}) 695 | } else if (details.url.startsWith('chrome-devtools://')) { 696 | // chrome-devtools requests are allowed 697 | // XXX Is this a security problem? 698 | callback({}) 699 | } else if (details.url.startsWith('blob:')) { 700 | // blobs are allowed? 701 | // XXX Is this a security problem? 702 | callback({}) 703 | } else { 704 | log.warn(`Document attempted ${details.method} ${details.url}`); 705 | callback({cancel: true}); 706 | } 707 | }) 708 | 709 | // Menu 710 | menu = Menu.buildFromTemplate(template); 711 | Menu.setApplicationMenu(menu); 712 | 713 | if (openfirst) { 714 | openPath(openfirst); 715 | openfirst = null; 716 | } else { 717 | // The default window 718 | createDefaultWindow(); 719 | } 720 | }); 721 | 722 | // Quit when all windows are closed. 723 | app.on('window-all-closed', () => { 724 | if (process.env.RUN_TESTS) { 725 | // in test mode, don't quit 726 | return 727 | } 728 | // On OS X it is common for applications and their menu bar 729 | // to stay active until the user quits explicitly with Cmd + Q 730 | if (process.platform !== 'darwin') { 731 | app.quit(); 732 | } 733 | }); 734 | 735 | app.on('activate', () => { 736 | // On OS X it's common to re-create a window in the app when the 737 | // dock icon is clicked and there are no other windows open. 738 | if (BrowserWindow.getAllWindows().length === 0) { 739 | createDefaultWindow(); 740 | } 741 | }); 742 | 743 | function promptOpenFile() { 744 | dialog.showOpenDialog({ 745 | title: 'Open...', 746 | properties: ['openFile'], 747 | filters: [ 748 | {name: 'LHTML', extensions: ['lhtml']}, 749 | {name: 'All Files', extensions: ['*']}, 750 | ], 751 | }, (filePaths) => { 752 | if (!filePaths) { 753 | return; 754 | } 755 | openPath(filePaths[0]); 756 | }); 757 | } 758 | 759 | function newFromTemplate() { 760 | let defaultPath = getDefaultTemplateDir(); 761 | dialog.showOpenDialog({ 762 | title: 'New From Template...', 763 | defaultPath: defaultPath, 764 | properties: ['openFile'], 765 | filters: [ 766 | {name: 'LHTML', extensions: ['lhtml']}, 767 | {name: 'All Files', extensions: ['*']}, 768 | ], 769 | }, (filePaths) => { 770 | if (!filePaths) { 771 | return; 772 | } 773 | let doc = openPath(filePaths[0]); 774 | doc.changeSavePath(null); 775 | }); 776 | } 777 | 778 | function openDirectory() { 779 | dialog.showOpenDialog({ 780 | title: 'Open Directory...', 781 | properties: ['openDirectory'], 782 | }, (filePaths) => { 783 | if (!filePaths) { 784 | return; 785 | } 786 | openPath(filePaths[0]); 787 | }); 788 | } 789 | 790 | function openPath(path) { 791 | // Is it already opened? 792 | let existing = _(OPENDOCUMENTS) 793 | .values() 794 | .filter(doc => { 795 | return doc.save_path === path; 796 | }) 797 | .first(); 798 | if (existing) { 799 | BrowserWindow.fromId(existing.window_id).focus(); 800 | return; 801 | } 802 | 803 | var dirPath; 804 | let doc = new Document(path); 805 | try { 806 | let working_dir = doc.working_dir; 807 | } catch (err) { 808 | dialog.showErrorBox("Error opening file", "Filename: " + path + "\n\n" + err); 809 | return; 810 | } 811 | 812 | // Open a new window 813 | let win = createLHTMLWindow(); 814 | doc.attachToWindow(win.id); 815 | 816 | WINDOW2DOC_INFO[win.id] = OPENDOCUMENTS[doc.id] = doc; 817 | var url = `lhtml://${doc.id}/index.html`; 818 | win.webContents.on('did-finish-load', (event) => { 819 | win.webContents.send('load-file', url); 820 | }); 821 | return doc; 822 | } 823 | 824 | function reloadFocusedDoc() { 825 | let current = currentWindow(); 826 | if (current) { 827 | current.webContents.send('reload-file'); 828 | current.setDocumentEdited(false); 829 | } 830 | } 831 | 832 | function saveFocusedDoc() { 833 | let current = currentWindow(); 834 | if (current) { 835 | let doc = WINDOW2DOC_INFO[current.id]; 836 | if (!doc) { 837 | return; 838 | } 839 | return doc.save(); 840 | } 841 | } 842 | 843 | function saveAsFocusedDoc() { 844 | let current = currentWindow(); 845 | if (current) { 846 | let doc = WINDOW2DOC_INFO[current.id]; 847 | if (!doc) { 848 | return; 849 | } 850 | return doc.saveAs(); 851 | } 852 | } 853 | 854 | function getDefaultTemplateDir() { 855 | let template_dir = null; 856 | try { 857 | template_dir = Path.join(app.getPath('documents'), 'lhtml_templates'); 858 | fs.ensureDirSync(template_dir); 859 | } catch(err) { 860 | } 861 | return template_dir; 862 | } 863 | 864 | function saveTemplateFocusedDoc() { 865 | let current = currentWindow(); 866 | if (!current) { 867 | return Promise.resolve(null); 868 | } 869 | let doc = WINDOW2DOC_INFO[current.id]; 870 | if (!doc) { 871 | return Promise.resolve(null); 872 | } 873 | let template_dir = getDefaultTemplateDir(); 874 | return new Promise((resolve, reject) => { 875 | dialog.showSaveDialog({ 876 | defaultPath: template_dir, 877 | filters: [ 878 | {name: 'LHTML', extensions: ['lhtml']}, 879 | {name: 'All Files', extensions: ['*']}, 880 | ], 881 | }, dst => { 882 | if (!dst) { 883 | return; 884 | } 885 | let former_path = doc.save_path; 886 | doc.changeSavePath(dst); 887 | return doc.save().then(result => { 888 | doc.changeSavePath(former_path); 889 | return result; 890 | }, err => { 891 | doc.changeSavePath(former_path); 892 | throw err; 893 | }) 894 | .then(resolve) 895 | .catch(reject); 896 | }); 897 | }) 898 | } 899 | 900 | 901 | function closeFocusedDoc() { 902 | let current = currentWindow(); 903 | if (current) { 904 | return new Promise((resolve, reject) => { 905 | current.close_promise = function(result) { 906 | delete current.close_promise; 907 | resolve(result); 908 | }; 909 | current.close(); 910 | }) 911 | } else { 912 | return Promise.resolve(false); 913 | } 914 | } 915 | 916 | 917 | function printToPDF() { 918 | let webview = currentWebViewWebContents(); 919 | if (webview) { 920 | webview.printToPDF({ 921 | printBackground: true, 922 | }, (err, data) => { 923 | if (err) throw err; 924 | dialog.showSaveDialog({ 925 | filters: [ 926 | {name: 'PDF', extensions: ['pdf']}, 927 | {name: 'All Files', extensions: ['*']}, 928 | ], 929 | }, dst => { 930 | if (!dst) { 931 | return; 932 | } 933 | fs.writeFile(dst, data, err => { 934 | if (err) throw err; 935 | log.debug('wrote PDF', dst); 936 | }) 937 | }); 938 | 939 | }) 940 | } 941 | } 942 | 943 | 944 | 945 | function toggleMainDevTools() { 946 | currentWindow().toggleDevTools(); 947 | } 948 | function toggleDocumentDevTools() { 949 | currentWindow().webContents.send('toggleDevTools'); 950 | } 951 | function showLogFileInFolder() { 952 | shell.showItemInFolder(log.transports.file.file); 953 | } 954 | 955 | function currentWindow() { 956 | let win = BrowserWindow.getFocusedWindow(); 957 | if (win) { 958 | while (win.webContents.hostWebContents) { 959 | win = BrowserWindow.fromWebContents(win.webContents.hostWebContents); 960 | } 961 | } 962 | return win; 963 | } 964 | function currentWebViewWebContents() { 965 | let win = BrowserWindow.getFocusedWindow(); 966 | return _.find(webContents.getAllWebContents(), wc => { 967 | return wc.hostWebContents === win.webContents; 968 | }) 969 | } 970 | 971 | let RPC = new RPCService(ipcMain); 972 | RPC.listen(); 973 | RPC.handlers = { 974 | echo: (ctx, data) => { 975 | return 'echo: ' + data; 976 | }, 977 | acquire_io_lock: (ctx) => { 978 | let doc = OPENDOCUMENTS[ctx.sender_id]; 979 | return doc.lock.acquire('io'); 980 | }, 981 | release_io_lock: (ctx) => { 982 | let doc = OPENDOCUMENTS[ctx.sender_id]; 983 | return doc.lock.release('io'); 984 | }, 985 | save: (ctx, data) => { 986 | let window_id = OPENDOCUMENTS[ctx.sender_id].window_id; 987 | let win = BrowserWindow.fromId(window_id); 988 | return _saveDoc(win); 989 | }, 990 | set_document_edited: (ctx, edited) => { 991 | let window_id = OPENDOCUMENTS[ctx.sender_id].window_id; 992 | let win = BrowserWindow.fromId(window_id); 993 | win.setDocumentEdited(edited); 994 | return edited; 995 | }, 996 | get_chrootfs_root: (ctx) => { 997 | let doc = OPENDOCUMENTS[ctx.sender_id]; 998 | return doc.working_dir; 999 | }, 1000 | get_document_path: (ctx) => { 1001 | let doc = OPENDOCUMENTS[ctx.sender_id]; 1002 | return doc.save_path; 1003 | }, 1004 | suggest_size: (ctx, size) => { 1005 | let window_id = OPENDOCUMENTS[ctx.sender_id].window_id; 1006 | let win = BrowserWindow.fromId(window_id); 1007 | let current = win.getSize(); 1008 | 1009 | let newsize = { 1010 | width: size.width || current[0], 1011 | height: size.height || current[1], 1012 | }; 1013 | 1014 | // limit it to the size of the screen 1015 | let display = electron.screen.getDisplayMatching(win.getBounds()); 1016 | if (newsize.width > display.workAreaSize.width) { 1017 | newsize.width = display.workAreaSize.width; 1018 | } 1019 | if (newsize.height > display.workAreaSize.height) { 1020 | newsize.height = display.workAreaSize.height; 1021 | } 1022 | 1023 | if (!win.throttledSetSize) { 1024 | win.throttledSetSize = _.debounce(win.setSize, 600, { 1025 | leading: true, 1026 | trailing: false, 1027 | }); 1028 | } 1029 | 1030 | win.throttledSetSize(newsize.width, newsize.height); 1031 | 1032 | current = win.getSize(); 1033 | return { 1034 | width: current[0], 1035 | height: current[1], 1036 | }; 1037 | }, 1038 | }; 1039 | 1040 | module.exports = { 1041 | app, 1042 | openPath, 1043 | reloadFocusedDoc, 1044 | closeFocusedDoc, 1045 | saveFocusedDoc, 1046 | saveAsFocusedDoc, 1047 | saveTemplateFocusedDoc, 1048 | }; 1049 | 1050 | // Test interface 1051 | if (process.env.RUN_TESTS) { 1052 | console.log('RUNNING TESTS'); 1053 | require('../functest/e2e.js'); 1054 | } --------------------------------------------------------------------------------