2
0

desktop.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  1. import * as base from './base.js';
  2. import * as child_process from 'child_process';
  3. import * as electron from 'electron';
  4. import * as fs from 'fs';
  5. import * as http from 'http';
  6. import * as https from 'https';
  7. import * as node from './node.js';
  8. import * as os from 'os';
  9. import * as path from 'path';
  10. import * as url from 'url';
  11. import * as view from './view.js';
  12. const desktop = {};
  13. desktop.Host = class {
  14. constructor() {
  15. this._document = window.document;
  16. this._window = window;
  17. this._global = global;
  18. this._telemetry = new base.Telemetry(this._window);
  19. process.on('uncaughtException', (error) => {
  20. this.exception(error, true);
  21. this.message(error.message);
  22. });
  23. this._global.eval = () => {
  24. throw new Error('eval.eval() not supported.');
  25. };
  26. this._window.eval = () => {
  27. throw new Error('window.eval() not supported.');
  28. };
  29. this._window.addEventListener('unload', () => {
  30. if (typeof __coverage__ !== 'undefined') {
  31. const dir = path.join(process.cwd(), 'dist', 'nyc', '.nyc_output');
  32. if (!fs.existsSync(dir)) {
  33. fs.mkdirSync(dir, { recursive: true });
  34. }
  35. const base = path.basename(window.location.pathname, '.html');
  36. const file = path.join(dir, `${base}.json`);
  37. /* eslint-disable-next-line no-undef */
  38. fs.writeFileSync(file, JSON.stringify(__coverage__));
  39. }
  40. });
  41. this._environment = electron.ipcRenderer.sendSync('get-environment', {});
  42. this._environment.menu = this._environment.titlebar && this._environment.platform !== 'darwin';
  43. this._files = [];
  44. electron.ipcRenderer.on('open', (sender, data) => {
  45. this._open(data);
  46. });
  47. this._element('menu-button').style.opacity = 0;
  48. if (!/^\d+\.\d+\.\d+$/.test(this.version)) {
  49. throw new Error('Invalid version.');
  50. }
  51. const metadata = [];
  52. metadata.push(os.arch());
  53. let packager = '';
  54. if (process.platform === 'linux') {
  55. try {
  56. child_process.execFileSync('dpkg', ['-S', process.execPath]);
  57. packager = 'deb';
  58. } catch {
  59. try {
  60. child_process.execFileSync("rpm", ["-qf", process.execPath]);
  61. packager = 'rpm';
  62. } catch {
  63. // continue regardless of error
  64. }
  65. }
  66. }
  67. metadata.push(packager);
  68. this._metadata = metadata.join('|');
  69. }
  70. get window() {
  71. return this._window;
  72. }
  73. get document() {
  74. return this._document;
  75. }
  76. get version() {
  77. return this._environment.version;
  78. }
  79. get type() {
  80. return 'Electron';
  81. }
  82. get metadata() {
  83. return this._metadata;
  84. }
  85. async view(view) {
  86. this._view = view;
  87. const age = async () => {
  88. const days = (new Date() - new Date(this._environment.date)) / (24 * 60 * 60 * 1000);
  89. if (days > 180) {
  90. this.document.body.classList.remove('spinner');
  91. const link = this._element('logo-github').href;
  92. for (;;) {
  93. /* eslint-disable-next-line no-await-in-loop */
  94. await this.message('Please update to the newest version.', null, 'Download');
  95. this.openURL(link);
  96. }
  97. }
  98. return Promise.resolve();
  99. };
  100. const consent = async () => {
  101. const time = this.get('consent');
  102. if (!time || (Date.now() - time) > 30 * 24 * 60 * 60 * 1000) {
  103. let consent = true;
  104. try {
  105. const content = await this._request('https://ipinfo.io/json', { 'Content-Type': 'application/json' }, 2000);
  106. const json = JSON.parse(content);
  107. const countries = ['AT', 'BE', 'BG', 'HR', 'CZ', 'CY', 'DK', 'EE', 'FI', 'FR', 'DE', 'EL', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'NO', 'PL', 'PT', 'SK', 'ES', 'SE', 'GB', 'UK', 'GR', 'EU', 'RO'];
  108. if (json && json.country && countries.indexOf(json.country) === -1) {
  109. consent = false;
  110. }
  111. } catch {
  112. // continue regardless of error
  113. }
  114. if (consent) {
  115. this.document.body.classList.remove('spinner');
  116. await this.message('This app uses cookies to report errors and anonymous usage information.', null, 'Accept');
  117. }
  118. this.set('consent', Date.now());
  119. }
  120. };
  121. const telemetry = async () => {
  122. if (this._environment.packaged) {
  123. const measurement_id = '848W2NVWVH';
  124. const user = this.get('user') || null;
  125. const session = this.get('session') || null;
  126. await this._telemetry.start(`G-${measurement_id}`, user && user.indexOf('.') !== -1 ? user : null, session);
  127. this._telemetry.send('page_view', {
  128. app_name: this.type,
  129. app_version: this.version,
  130. app_metadata: this.metadata
  131. });
  132. this._telemetry.send('scroll', {
  133. percent_scrolled: 90,
  134. app_name: this.type,
  135. app_version: this.version,
  136. app_metadata: this.metadata
  137. });
  138. this.set('user', this._telemetry.get('client_id'));
  139. this.set('session', this._telemetry.session);
  140. }
  141. };
  142. await age();
  143. await consent();
  144. await telemetry();
  145. }
  146. async start() {
  147. if (this._files) {
  148. const files = this._files;
  149. delete this._files;
  150. if (files.length > 0) {
  151. const data = files.pop();
  152. this._open(data);
  153. }
  154. }
  155. this._window.addEventListener('focus', () => {
  156. this._document.body.classList.add('active');
  157. });
  158. this._window.addEventListener('blur', () => {
  159. this._document.body.classList.remove('active');
  160. });
  161. if (this._document.hasFocus()) {
  162. this._document.body.classList.add('active');
  163. }
  164. electron.ipcRenderer.on('recents', (sender, data) => {
  165. this._view.recents(data);
  166. });
  167. electron.ipcRenderer.on('export', (sender, data) => {
  168. this._view.export(data.file);
  169. });
  170. electron.ipcRenderer.on('cut', () => {
  171. this.document.execCommand('cut');
  172. });
  173. electron.ipcRenderer.on('copy', () => {
  174. this.document.execCommand('copy');
  175. });
  176. electron.ipcRenderer.on('paste', () => {
  177. if (this.document.queryCommandSupported('paste')) {
  178. this.document.execCommand('paste');
  179. } else if (this.document.queryCommandSupported('insertText')) {
  180. const content = electron.clipboard.readText();
  181. this.document.execCommand('insertText', false, content);
  182. }
  183. });
  184. electron.ipcRenderer.on('selectall', () => {
  185. this.document.execCommand('selectall');
  186. });
  187. electron.ipcRenderer.on('toggle', (sender, name) => {
  188. this._view.toggle(name);
  189. this.update({ ...this._view.options });
  190. });
  191. electron.ipcRenderer.on('zoom-in', () => {
  192. this._element('zoom-in-button').click();
  193. });
  194. electron.ipcRenderer.on('zoom-out', () => {
  195. this._element('zoom-out-button').click();
  196. });
  197. electron.ipcRenderer.on('zoom-reset', () => {
  198. this._view.resetZoom();
  199. });
  200. electron.ipcRenderer.on('show-properties', () => {
  201. this._element('sidebar-target-button').click();
  202. });
  203. electron.ipcRenderer.on('find', () => {
  204. this._view.find();
  205. });
  206. electron.ipcRenderer.on('about', () => {
  207. this._view.about();
  208. });
  209. this._element('titlebar-close').addEventListener('click', () => {
  210. electron.ipcRenderer.sendSync('window-close', {});
  211. });
  212. this._element('titlebar-toggle').addEventListener('click', () => {
  213. electron.ipcRenderer.sendSync('window-toggle', {});
  214. });
  215. this._element('titlebar-minimize').addEventListener('click', () => {
  216. electron.ipcRenderer.sendSync('window-minimize', {});
  217. });
  218. electron.ipcRenderer.on('window-state', (sender, data) => {
  219. if (this._environment.titlebar) {
  220. this._element('target').style.marginTop = '32px';
  221. this._element('target').style.height = 'calc(100% - 32px)';
  222. this._element('sidebar-title').style.marginTop = '24px';
  223. this._element('sidebar-closebutton').style.marginTop = '24px';
  224. this._element('titlebar').classList.add('titlebar-visible');
  225. }
  226. if (this._environment.titlebar && this._environment.platform !== 'darwin' && !data.fullscreen) {
  227. this._element('titlebar-control-box').classList.add('titlebar-control-box-visible');
  228. } else {
  229. this._element('titlebar-control-box').classList.remove('titlebar-control-box-visible');
  230. }
  231. this._element('menu-button').style.opacity = this._environment.menu ? 1 : 0;
  232. this._element('titlebar-maximize').style.opacity = data.maximized ? 0 : 1;
  233. this._element('titlebar-restore').style.opacity = data.maximized ? 1 : 0;
  234. this._element('titlebar-toggle').setAttribute('title', data.maximized ? 'Restore' : 'Maximize');
  235. });
  236. electron.ipcRenderer.sendSync('update-window-state', {});
  237. const openFileButton = this._element('open-file-button');
  238. if (openFileButton) {
  239. openFileButton.addEventListener('click', async () => {
  240. await this.execute('open');
  241. });
  242. }
  243. this.document.addEventListener('dragover', (e) => {
  244. e.preventDefault();
  245. });
  246. this.document.addEventListener('drop', (e) => {
  247. e.preventDefault();
  248. });
  249. this.document.body.addEventListener('drop', (e) => {
  250. e.preventDefault();
  251. const files = Array.from(e.dataTransfer.files);
  252. const paths = files.map((file) => electron.webUtils.getPathForFile(file));
  253. if (paths.length > 0) {
  254. electron.ipcRenderer.send('drop-paths', { paths });
  255. }
  256. return false;
  257. });
  258. this._view.show('welcome');
  259. }
  260. environment(name) {
  261. return this._environment[name];
  262. }
  263. async error(message) {
  264. await this.message(message, true, 'OK');
  265. }
  266. async require(id) {
  267. return import(`${id}.js`);
  268. }
  269. worker(id) {
  270. return new this.window.Worker(`${id}.js`, { type: 'module' });
  271. }
  272. async save(name, extension, defaultPath) {
  273. return new Promise((resolve, reject) => {
  274. electron.ipcRenderer.once('show-save-dialog-complete', (event, data) => {
  275. if (data.error) {
  276. reject(new Error(data.error));
  277. } else if (data.canceled) {
  278. resolve(null);
  279. } else {
  280. resolve(data.filePath);
  281. }
  282. });
  283. electron.ipcRenderer.send('show-save-dialog', {
  284. title: 'Export Tensor',
  285. defaultPath,
  286. buttonLabel: 'Export',
  287. filters: [{ name, extensions: [extension] }]
  288. });
  289. });
  290. }
  291. async export(file, blob) {
  292. const window = this.window;
  293. const reader = new window.FileReader();
  294. reader.onload = (e) => {
  295. const data = new Uint8Array(e.target.result);
  296. fs.writeFile(file, data, null, async (error) => {
  297. if (error) {
  298. await this._view.error(error, 'Error writing file.');
  299. }
  300. });
  301. };
  302. let error = null;
  303. if (!blob) {
  304. error = new Error(`Export blob is '${JSON.stringify(blob)}'.`);
  305. } else if (blob instanceof window.Blob === false) {
  306. error = new Error(`Export blob type is '${typeof blob}'.`);
  307. }
  308. if (error) {
  309. await this._view.error(error, 'Error exporting image.');
  310. } else {
  311. reader.readAsArrayBuffer(blob);
  312. }
  313. }
  314. async execute(name, value) {
  315. return new Promise((resolve, reject) => {
  316. electron.ipcRenderer.once('execute-complete', (event, data) => {
  317. if (data.error) {
  318. reject(new Error(data.error));
  319. } else {
  320. resolve(data.value);
  321. }
  322. });
  323. electron.ipcRenderer.send('execute', { name, value });
  324. });
  325. }
  326. async request(file, encoding, basename) {
  327. return new Promise((resolve, reject) => {
  328. const dirname = path.dirname(url.fileURLToPath(import.meta.url));
  329. const pathname = path.join(basename || dirname, file);
  330. fs.stat(pathname, (err, stat) => {
  331. if (err && err.code === 'ENOENT') {
  332. reject(new Error(`The file '${file}' does not exist.`));
  333. } else if (err) {
  334. reject(err);
  335. } else if (!stat.isFile()) {
  336. reject(new Error(`The path '${file}' is not a file.`));
  337. } else if (stat && stat.size < 0x40000000) {
  338. fs.readFile(pathname, encoding, (err, data) => {
  339. if (err) {
  340. reject(err);
  341. } else {
  342. resolve(encoding ? data : new base.BinaryStream(data));
  343. }
  344. });
  345. } else if (encoding) {
  346. reject(new Error(`The file '${file}' size (${stat.size.toString()}) for encoding '${encoding}' is greater than 2 GB.`));
  347. } else {
  348. const stream = new node.FileStream(pathname, 0, stat.size, stat.mtimeMs);
  349. resolve(stream);
  350. }
  351. });
  352. });
  353. }
  354. openURL(url) {
  355. electron.shell.openExternal(url);
  356. }
  357. exception(error, fatal) {
  358. if (this._telemetry && error) {
  359. try {
  360. const name = error.name ? `${error.name}: ` : '';
  361. const message = error.message ? error.message : JSON.stringify(error);
  362. let context = '';
  363. let stack = '';
  364. if (error.stack) {
  365. const format = (file, line, column) => {
  366. return `${file.split('\\').join('/').split('/').pop()}:${line}:${column}`;
  367. };
  368. const match = error.stack.match(/\n {4}at (.*) \((.*):(\d*):(\d*)\)/);
  369. if (match) {
  370. stack = `${match[1]} (${format(match[2], match[3], match[4])})`;
  371. } else {
  372. const match = error.stack.match(/\n {4}at (.*):(\d*):(\d*)/);
  373. if (match) {
  374. stack = `(${format(match[1], match[2], match[3])})`;
  375. } else {
  376. const match = error.stack.match(/.*\n\s*(.*)\s*/);
  377. if (match) {
  378. [, stack] = match;
  379. }
  380. }
  381. }
  382. }
  383. if (error.context) {
  384. context = typeof error.context === 'string' ? error.context : JSON.stringify(error.context);
  385. }
  386. this._telemetry.send('exception', {
  387. app_name: this.type,
  388. app_version: this.version,
  389. app_metadata: this.metadata,
  390. error_name: name,
  391. error_message: message,
  392. error_context: context,
  393. error_stack: stack,
  394. error_fatal: fatal ? true : false
  395. });
  396. } catch {
  397. // continue regardless of error
  398. }
  399. }
  400. }
  401. event(name, params) {
  402. if (name && params) {
  403. params.app_name = this.type;
  404. params.app_version = this.version;
  405. params.app_metadata = this.metadata;
  406. this._telemetry.send(name, params);
  407. }
  408. }
  409. async _context(location) {
  410. const basename = path.basename(location);
  411. const stat = fs.statSync(location);
  412. if (stat.isFile()) {
  413. const dirname = path.dirname(location);
  414. const stream = await this.request(basename, null, dirname);
  415. return new desktop.Context(this, dirname, basename, stream);
  416. } else if (stat.isDirectory()) {
  417. const entries = new Map();
  418. const walk = (dir) => {
  419. for (const item of fs.readdirSync(dir)) {
  420. const pathname = path.join(dir, item);
  421. const stat = fs.statSync(pathname);
  422. if (stat.isDirectory()) {
  423. walk(pathname);
  424. } else if (stat.isFile()) {
  425. const stream = new node.FileStream(pathname, 0, stat.size, stat.mtimeMs);
  426. const name = pathname.split(path.sep).join(path.posix.sep);
  427. entries.set(name, stream);
  428. }
  429. }
  430. };
  431. walk(location);
  432. return new desktop.Context(this, location, basename, null, entries);
  433. }
  434. throw new Error(`Unsupported path stat '${JSON.stringify(stat)}'.`);
  435. }
  436. async _open(location) {
  437. if (this._files) {
  438. this._files.push(location);
  439. return;
  440. }
  441. const path = location.path;
  442. const stat = fs.existsSync(path) ? fs.statSync(path) : null;
  443. const size = stat && stat.isFile() ? stat.size : 0;
  444. if (path && this._view.accept(path, size)) {
  445. this._view.show('welcome spinner');
  446. let context = null;
  447. try {
  448. context = await this._context(path);
  449. this._telemetry.set('session_engaged', 1);
  450. } catch (error) {
  451. await this._view.error(error, 'Error while reading file.');
  452. this.update({ path: null });
  453. return;
  454. }
  455. try {
  456. const attachment = await this._view.attach(context);
  457. if (attachment) {
  458. this._view.show(null);
  459. } else {
  460. const model = await this._view.open(context);
  461. this._view.show(null);
  462. const options = { ...this._view.options };
  463. if (model) {
  464. options.path = path;
  465. this._title(location.label);
  466. } else {
  467. options.path = path;
  468. this._title('');
  469. }
  470. electron.ipcRenderer.send('update-recents', { path });
  471. this.update(options);
  472. }
  473. } catch (error) {
  474. const options = { ...this._view.options };
  475. if (error) {
  476. await this._view.error(error);
  477. }
  478. this.update(options);
  479. }
  480. }
  481. }
  482. _request(location, headers, timeout) {
  483. const window = this.window;
  484. return new Promise((resolve, reject) => {
  485. const url = new window.URL(location);
  486. const protocol = url.protocol === 'https:' ? https : http;
  487. const options = {};
  488. options.headers = headers;
  489. if (timeout) {
  490. options.timeout = timeout;
  491. }
  492. const request = protocol.request(location, options, (response) => {
  493. if (response.statusCode === 200) {
  494. let data = '';
  495. response.on('data', (chunk) => {
  496. data += chunk;
  497. });
  498. response.on('error', (err) => {
  499. reject(err);
  500. });
  501. response.on('end', () => {
  502. resolve(data);
  503. });
  504. } else {
  505. const error = new Error(`The web request failed with status code '${response.statusCode}'.`);
  506. error.context = location;
  507. reject(error);
  508. }
  509. });
  510. request.on("error", (err) => {
  511. reject(err);
  512. });
  513. request.on("timeout", () => {
  514. request.destroy();
  515. const error = new Error('The web request timed out.');
  516. error.context = url;
  517. reject(error);
  518. });
  519. request.end();
  520. });
  521. }
  522. get(name) {
  523. try {
  524. return electron.ipcRenderer.sendSync('get-configuration', { name });
  525. } catch {
  526. // continue regardless of error
  527. }
  528. return undefined;
  529. }
  530. set(name, value) {
  531. try {
  532. electron.ipcRenderer.sendSync('set-configuration', { name, value });
  533. } catch {
  534. // continue regardless of error
  535. }
  536. }
  537. delete(name) {
  538. try {
  539. electron.ipcRenderer.sendSync('delete-configuration', { name });
  540. } catch {
  541. // continue regardless of error
  542. }
  543. }
  544. _title(label) {
  545. const element = this._element('titlebar-content-text');
  546. if (element) {
  547. element.innerHTML = '';
  548. if (label) {
  549. const path = label.split(this._environment.separator || '/');
  550. for (let i = 0; i < path.length; i++) {
  551. const span = this.document.createElement('span');
  552. span.innerHTML = ` ${path[i]} ${i === path.length - 1 ? '' : '<svg class="titlebar-icon" aria-hidden="true"><use xlink:href="#icon-arrow-right"></use></svg>'}`;
  553. element.appendChild(span);
  554. }
  555. }
  556. }
  557. }
  558. _element(id) {
  559. return this.document.getElementById(id);
  560. }
  561. update(data) {
  562. electron.ipcRenderer.send('window-update', data);
  563. }
  564. async message(message, alert, action) {
  565. return new Promise((resolve) => {
  566. const type = this.document.body.getAttribute('class');
  567. this._element('message-text').innerText = message || '';
  568. const button = this._element('message-button');
  569. if (action) {
  570. button.style.removeProperty('display');
  571. button.innerText = action;
  572. button.onclick = () => {
  573. button.onclick = null;
  574. this.document.body.setAttribute('class', type);
  575. resolve(0);
  576. };
  577. } else {
  578. button.style.display = 'none';
  579. button.onclick = null;
  580. }
  581. if (alert) {
  582. this.document.body.setAttribute('class', 'alert');
  583. } else {
  584. this.document.body.classList.add('notification');
  585. this.document.body.classList.remove('default');
  586. }
  587. if (action) {
  588. button.focus();
  589. }
  590. });
  591. }
  592. };
  593. desktop.Context = class {
  594. constructor(host, folder, identifier, stream, entries) {
  595. this._host = host;
  596. this._folder = folder;
  597. this._identifier = identifier;
  598. this._stream = stream;
  599. this._entries = entries || new Map();
  600. }
  601. get identifier() {
  602. return this._identifier;
  603. }
  604. get stream() {
  605. return this._stream;
  606. }
  607. get entries() {
  608. return this._entries;
  609. }
  610. async request(file, encoding, base) {
  611. return this._host.request(file, encoding, base === undefined ? this._folder : base);
  612. }
  613. async require(id) {
  614. return this._host.require(id);
  615. }
  616. error(error, fatal) {
  617. this._host.exception(error, fatal);
  618. }
  619. };
  620. if (typeof window !== 'undefined') {
  621. window.addEventListener('load', () => {
  622. const value = new desktop.Host();
  623. window.__view__ = new view.View(value);
  624. window.__view__.start();
  625. });
  626. }