desktop.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665
  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 reader = new FileReader();
  293. reader.onload = (e) => {
  294. const data = new Uint8Array(e.target.result);
  295. fs.writeFile(file, data, null, async (error) => {
  296. if (error) {
  297. await this._view.error(error, 'Error writing file.');
  298. }
  299. });
  300. };
  301. let error = null;
  302. if (!blob) {
  303. error = new Error(`Export blob is '${JSON.stringify(blob)}'.`);
  304. } else if (!(blob instanceof Blob)) {
  305. error = new Error(`Export blob type is '${typeof blob}'.`);
  306. }
  307. if (error) {
  308. await this._view.error(error, 'Error exporting image.');
  309. } else {
  310. reader.readAsArrayBuffer(blob);
  311. }
  312. }
  313. async execute(name, value) {
  314. return new Promise((resolve, reject) => {
  315. electron.ipcRenderer.once('execute-complete', (event, data) => {
  316. if (data.error) {
  317. reject(new Error(data.error));
  318. } else {
  319. resolve(data.value);
  320. }
  321. });
  322. electron.ipcRenderer.send('execute', { name, value });
  323. });
  324. }
  325. async request(file, encoding, basename) {
  326. return new Promise((resolve, reject) => {
  327. const dirname = path.dirname(url.fileURLToPath(import.meta.url));
  328. const pathname = path.join(basename || dirname, file);
  329. fs.stat(pathname, (err, stat) => {
  330. if (err && err.code === 'ENOENT') {
  331. reject(new Error(`The file '${file}' does not exist.`));
  332. } else if (err) {
  333. reject(err);
  334. } else if (!stat.isFile()) {
  335. reject(new Error(`The path '${file}' is not a file.`));
  336. } else if (stat && stat.size < 0x40000000) {
  337. fs.readFile(pathname, encoding, (err, data) => {
  338. if (err) {
  339. reject(err);
  340. } else {
  341. resolve(encoding ? data : new base.BinaryStream(data));
  342. }
  343. });
  344. } else if (encoding) {
  345. reject(new Error(`The file '${file}' size (${stat.size.toString()}) for encoding '${encoding}' is greater than 2 GB.`));
  346. } else {
  347. const stream = new node.FileStream(pathname, 0, stat.size, stat.mtimeMs);
  348. resolve(stream);
  349. }
  350. });
  351. });
  352. }
  353. openURL(url) {
  354. electron.shell.openExternal(url);
  355. }
  356. exception(error, fatal) {
  357. if (this._telemetry && error) {
  358. try {
  359. const name = error.name ? `${error.name}: ` : '';
  360. const message = error.message ? error.message : JSON.stringify(error);
  361. let context = '';
  362. let stack = '';
  363. if (error.stack) {
  364. const format = (file, line, column) => {
  365. return `${file.split('\\').join('/').split('/').pop()}:${line}:${column}`;
  366. };
  367. const match = error.stack.match(/\n {4}at (.*) \((.*):(\d*):(\d*)\)/);
  368. if (match) {
  369. stack = `${match[1]} (${format(match[2], match[3], match[4])})`;
  370. } else {
  371. const match = error.stack.match(/\n {4}at (.*):(\d*):(\d*)/);
  372. if (match) {
  373. stack = `(${format(match[1], match[2], match[3])})`;
  374. } else {
  375. const match = error.stack.match(/.*\n\s*(.*)\s*/);
  376. if (match) {
  377. [, stack] = match;
  378. }
  379. }
  380. }
  381. }
  382. if (error.context) {
  383. context = typeof error.context === 'string' ? error.context : JSON.stringify(error.context);
  384. }
  385. this._telemetry.send('exception', {
  386. app_name: this.type,
  387. app_version: this.version,
  388. app_metadata: this.metadata,
  389. error_name: name,
  390. error_message: message,
  391. error_context: context,
  392. error_stack: stack,
  393. error_fatal: fatal ? true : false
  394. });
  395. } catch {
  396. // continue regardless of error
  397. }
  398. }
  399. }
  400. event(name, params) {
  401. if (name && params) {
  402. params.app_name = this.type;
  403. params.app_version = this.version;
  404. params.app_metadata = this.metadata;
  405. this._telemetry.send(name, params);
  406. }
  407. }
  408. async _context(location) {
  409. const basename = path.basename(location);
  410. const stat = fs.statSync(location);
  411. if (stat.isFile()) {
  412. const dirname = path.dirname(location);
  413. const stream = await this.request(basename, null, dirname);
  414. return new desktop.Context(this, dirname, basename, stream);
  415. } else if (stat.isDirectory()) {
  416. const entries = new Map();
  417. const walk = (dir) => {
  418. for (const item of fs.readdirSync(dir)) {
  419. const pathname = path.join(dir, item);
  420. const stat = fs.statSync(pathname);
  421. if (stat.isDirectory()) {
  422. walk(pathname);
  423. } else if (stat.isFile()) {
  424. const stream = new node.FileStream(pathname, 0, stat.size, stat.mtimeMs);
  425. const name = pathname.split(path.sep).join(path.posix.sep);
  426. entries.set(name, stream);
  427. }
  428. }
  429. };
  430. walk(location);
  431. return new desktop.Context(this, location, basename, null, entries);
  432. }
  433. throw new Error(`Unsupported path stat '${JSON.stringify(stat)}'.`);
  434. }
  435. async _open(location) {
  436. if (this._files) {
  437. this._files.push(location);
  438. return;
  439. }
  440. const path = location.path;
  441. const stat = fs.existsSync(path) ? fs.statSync(path) : null;
  442. const size = stat && stat.isFile() ? stat.size : 0;
  443. if (path && this._view.accept(path, size)) {
  444. this._view.show('welcome spinner');
  445. let context = null;
  446. try {
  447. context = await this._context(path);
  448. this._telemetry.set('session_engaged', 1);
  449. } catch (error) {
  450. await this._view.error(error, 'Error while reading file.');
  451. this.update({ path: null });
  452. return;
  453. }
  454. try {
  455. const attachment = await this._view.attach(context);
  456. if (attachment) {
  457. this._view.show(null);
  458. } else {
  459. const model = await this._view.open(context);
  460. this._view.show(null);
  461. const options = { ...this._view.options };
  462. if (model) {
  463. options.path = path;
  464. this._title(location.label);
  465. } else {
  466. options.path = path;
  467. this._title('');
  468. }
  469. electron.ipcRenderer.send('update-recents', { path });
  470. this.update(options);
  471. }
  472. } catch (error) {
  473. const options = { ...this._view.options };
  474. if (error) {
  475. await this._view.error(error);
  476. }
  477. this.update(options);
  478. }
  479. }
  480. }
  481. _request(location, headers, timeout) {
  482. return new Promise((resolve, reject) => {
  483. const url = new URL(location);
  484. const protocol = url.protocol === 'https:' ? https : http;
  485. const options = {};
  486. options.headers = headers;
  487. if (timeout) {
  488. options.timeout = timeout;
  489. }
  490. const request = protocol.request(location, options, (response) => {
  491. if (response.statusCode === 200) {
  492. let data = '';
  493. response.on('data', (chunk) => {
  494. data += chunk;
  495. });
  496. response.on('error', (err) => {
  497. reject(err);
  498. });
  499. response.on('end', () => {
  500. resolve(data);
  501. });
  502. } else {
  503. const error = new Error(`The web request failed with status code '${response.statusCode}'.`);
  504. error.context = location;
  505. reject(error);
  506. }
  507. });
  508. request.on("error", (err) => {
  509. reject(err);
  510. });
  511. request.on("timeout", () => {
  512. request.destroy();
  513. const error = new Error('The web request timed out.');
  514. error.context = url;
  515. reject(error);
  516. });
  517. request.end();
  518. });
  519. }
  520. get(name) {
  521. try {
  522. return electron.ipcRenderer.sendSync('get-configuration', { name });
  523. } catch {
  524. // continue regardless of error
  525. }
  526. return undefined;
  527. }
  528. set(name, value) {
  529. try {
  530. electron.ipcRenderer.sendSync('set-configuration', { name, value });
  531. } catch {
  532. // continue regardless of error
  533. }
  534. }
  535. delete(name) {
  536. try {
  537. electron.ipcRenderer.sendSync('delete-configuration', { name });
  538. } catch {
  539. // continue regardless of error
  540. }
  541. }
  542. _title(label) {
  543. const element = this._element('titlebar-content-text');
  544. if (element) {
  545. element.innerHTML = '';
  546. if (label) {
  547. const path = label.split(this._environment.separator || '/');
  548. for (let i = 0; i < path.length; i++) {
  549. const span = this.document.createElement('span');
  550. span.innerHTML = ` ${path[i]} ${i === path.length - 1 ? '' : '<svg class="titlebar-icon" aria-hidden="true"><use xlink:href="#icon-arrow-right"></use></svg>'}`;
  551. element.appendChild(span);
  552. }
  553. }
  554. }
  555. }
  556. _element(id) {
  557. return this.document.getElementById(id);
  558. }
  559. update(data) {
  560. electron.ipcRenderer.send('window-update', data);
  561. }
  562. async message(message, alert, action) {
  563. return new Promise((resolve) => {
  564. const type = this.document.body.getAttribute('class');
  565. this._element('message-text').innerText = message || '';
  566. const button = this._element('message-button');
  567. if (action) {
  568. button.style.removeProperty('display');
  569. button.innerText = action;
  570. button.onclick = () => {
  571. button.onclick = null;
  572. this.document.body.setAttribute('class', type);
  573. resolve(0);
  574. };
  575. } else {
  576. button.style.display = 'none';
  577. button.onclick = null;
  578. }
  579. if (alert) {
  580. this.document.body.setAttribute('class', 'alert');
  581. } else {
  582. this.document.body.classList.add('notification');
  583. this.document.body.classList.remove('default');
  584. }
  585. if (action) {
  586. button.focus();
  587. }
  588. });
  589. }
  590. };
  591. desktop.Context = class {
  592. constructor(host, folder, identifier, stream, entries) {
  593. this._host = host;
  594. this._folder = folder;
  595. this._identifier = identifier;
  596. this._stream = stream;
  597. this._entries = entries || new Map();
  598. }
  599. get identifier() {
  600. return this._identifier;
  601. }
  602. get stream() {
  603. return this._stream;
  604. }
  605. get entries() {
  606. return this._entries;
  607. }
  608. async request(file, encoding, base) {
  609. return this._host.request(file, encoding, base === undefined ? this._folder : base);
  610. }
  611. async require(id) {
  612. return this._host.require(id);
  613. }
  614. error(error, fatal) {
  615. this._host.exception(error, fatal);
  616. }
  617. };
  618. if (typeof window !== 'undefined') {
  619. window.addEventListener('load', () => {
  620. const value = new desktop.Host();
  621. window.__view__ = new view.View(value);
  622. window.__view__.start();
  623. });
  624. }