UI.js 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033
  1. import FSItem from './FSItem.js';
  2. import PuterDialog from './PuterDialog.js';
  3. import EventListener from '../lib/EventListener.js';
  4. // AppConnection provides an API for interacting with another app.
  5. // It's returned by UI methods, and cannot be constructed directly by user code.
  6. // For basic usage:
  7. // - postMessage(message) Send a message to the target app
  8. // - on('message', callback) Listen to messages from the target app
  9. class AppConnection extends EventListener {
  10. // targetOrigin for postMessage() calls to Puter
  11. #puterOrigin = '*';
  12. // Whether the target app is open
  13. #isOpen;
  14. // Whether the target app uses the Puter SDK, and so accepts messages
  15. // (Closing and close events will still function.)
  16. #usesSDK;
  17. constructor(messageTarget, appInstanceID, targetAppInstanceID, usesSDK) {
  18. super([
  19. 'message', // The target sent us something with postMessage()
  20. 'close', // The target app was closed
  21. ]);
  22. this.messageTarget = messageTarget;
  23. this.appInstanceID = appInstanceID;
  24. this.targetAppInstanceID = targetAppInstanceID;
  25. this.#isOpen = true;
  26. this.#usesSDK = usesSDK;
  27. // TODO: Set this.#puterOrigin to the puter origin
  28. window.addEventListener('message', event => {
  29. if (event.data.msg === 'messageToApp') {
  30. if (event.data.appInstanceID !== this.targetAppInstanceID) {
  31. // Message is from a different AppConnection; ignore it.
  32. return;
  33. }
  34. if (event.data.targetAppInstanceID !== this.appInstanceID) {
  35. console.error(`AppConnection received message intended for wrong app! appInstanceID=${this.appInstanceID}, target=${event.data.targetAppInstanceID}`);
  36. return;
  37. }
  38. this.emit('message', event.data.contents);
  39. return;
  40. }
  41. if (event.data.msg === 'appClosed') {
  42. if (event.data.appInstanceID !== this.targetAppInstanceID) {
  43. // Message is from a different AppConnection; ignore it.
  44. return;
  45. }
  46. this.#isOpen = false;
  47. this.emit('close', {
  48. appInstanceID: this.targetAppInstanceID,
  49. });
  50. }
  51. });
  52. }
  53. // Does the target app use the Puter SDK? If not, certain features will be unavailable.
  54. get usesSDK() { return this.#usesSDK; }
  55. // Send a message to the target app. Requires the target to use the Puter SDK.
  56. postMessage(message) {
  57. if (!this.#isOpen) {
  58. console.warn('Trying to post message on a closed AppConnection');
  59. return;
  60. }
  61. if (!this.#usesSDK) {
  62. console.warn('Trying to post message to a non-SDK app');
  63. return;
  64. }
  65. this.messageTarget.postMessage({
  66. msg: 'messageToApp',
  67. appInstanceID: this.appInstanceID,
  68. targetAppInstanceID: this.targetAppInstanceID,
  69. targetAppOrigin: '*', // TODO: Specify this somehow
  70. contents: message,
  71. }, this.#puterOrigin);
  72. }
  73. // Attempt to close the target application
  74. close() {
  75. if (!this.#isOpen) {
  76. console.warn('Trying to close an app on a closed AppConnection');
  77. return;
  78. }
  79. this.messageTarget.postMessage({
  80. msg: 'closeApp',
  81. appInstanceID: this.appInstanceID,
  82. targetAppInstanceID: this.targetAppInstanceID,
  83. }, this.#puterOrigin);
  84. }
  85. }
  86. class UI extends EventListener {
  87. // Used to generate a unique message id for each message sent to the host environment
  88. // we start from 1 because 0 is falsy and we want to avoid that for the message id
  89. #messageID = 1;
  90. // Holds the callback functions for the various events
  91. // that are triggered when a watched item has changed.
  92. itemWatchCallbackFunctions = [];
  93. // Holds the unique app instance ID that is provided by the host environment
  94. appInstanceID;
  95. // Holds the unique app instance ID for the parent (if any), which is provided by the host environment
  96. parentInstanceID;
  97. // If we have a parent app, holds an AppConnection to it
  98. #parentAppConnection = null;
  99. // Holds the callback functions for the various events
  100. // that can be triggered by the host environment's messages.
  101. #callbackFunctions = [];
  102. // onWindowClose() is executed right before the window is closed. Users can override this function
  103. // to perform a variety of tasks right before window is closed. Users can override this function.
  104. #onWindowClose;
  105. // When an item is opened by this app in any way onItemsOpened() is executed. Users can override this function.
  106. #onItemsOpened;
  107. #onLaunchedWithItems;
  108. // List of events that can be listened to.
  109. #eventNames;
  110. // The most recent value that we received for a given broadcast, by name.
  111. #lastBroadcastValue = new Map(); // name -> data
  112. // Replaces boilerplate for most methods: posts a message to the GUI with a unique ID, and sets a callback for it.
  113. #postMessageWithCallback = function(name, resolve, args = {}) {
  114. const msg_id = this.#messageID++;
  115. this.messageTarget?.postMessage({
  116. msg: name,
  117. env: this.env,
  118. appInstanceID: this.appInstanceID,
  119. uuid: msg_id,
  120. ...args,
  121. }, '*');
  122. //register callback
  123. this.#callbackFunctions[msg_id] = resolve;
  124. }
  125. constructor (appInstanceID, parentInstanceID, appID, env) {
  126. const eventNames = [
  127. 'localeChanged',
  128. 'themeChanged',
  129. ];
  130. super(eventNames);
  131. this.#eventNames = eventNames;
  132. this.appInstanceID = appInstanceID;
  133. this.parentInstanceID = parentInstanceID;
  134. this.appID = appID;
  135. this.env = env;
  136. if(this.env === 'app'){
  137. this.messageTarget = window.parent;
  138. }
  139. else if(this.env === 'gui'){
  140. return;
  141. }
  142. if (this.parentInstanceID) {
  143. this.#parentAppConnection = new AppConnection(this.messageTarget, this.appInstanceID, this.parentInstanceID, true);
  144. }
  145. // Tell the host environment that this app is using the Puter SDK and is ready to receive messages,
  146. // this will allow the OS to send custom messages to the app
  147. this.messageTarget?.postMessage({
  148. msg: "READY",
  149. appInstanceID: this.appInstanceID,
  150. }, '*');
  151. // When this app's window is focused send a message to the host environment
  152. window.addEventListener('focus', (e) => {
  153. this.messageTarget?.postMessage({
  154. msg: "windowFocused",
  155. appInstanceID: this.appInstanceID,
  156. }, '*');
  157. });
  158. // Bind the message event listener to the window
  159. let lastDraggedOverElement = null;
  160. window.addEventListener('message', async (e) => {
  161. // `error`
  162. if(e.data.error){
  163. throw e.data.error;
  164. }
  165. // `focus` event
  166. else if(e.data.msg && e.data.msg === 'focus'){
  167. window.focus();
  168. }
  169. // `click` event
  170. else if(e.data.msg && e.data.msg === 'click'){
  171. // Get the element that was clicked on and click it
  172. const clicked_el = document.elementFromPoint(e.data.x, e.data.y);
  173. if(clicked_el !== null)
  174. clicked_el.click();
  175. }
  176. // `dragover` event based on the `drag` event from the host environment
  177. else if(e.data.msg && e.data.msg === 'drag'){
  178. // Get the element being dragged over
  179. const draggedOverElement = document.elementFromPoint(e.data.x, e.data.y);
  180. if(draggedOverElement !== lastDraggedOverElement){
  181. // If the last element exists and is different from the current, dispatch a dragleave on it
  182. if(lastDraggedOverElement){
  183. const dragLeaveEvent = new Event('dragleave', {
  184. bubbles: true,
  185. cancelable: true,
  186. clientX: e.data.x,
  187. clientY: e.data.y
  188. });
  189. lastDraggedOverElement.dispatchEvent(dragLeaveEvent);
  190. }
  191. // If the current element exists and is different from the last, dispatch dragenter on it
  192. if(draggedOverElement){
  193. const dragEnterEvent = new Event('dragenter', {
  194. bubbles: true,
  195. cancelable: true,
  196. clientX: e.data.x,
  197. clientY: e.data.y
  198. });
  199. draggedOverElement.dispatchEvent(dragEnterEvent);
  200. }
  201. // Update the lastDraggedOverElement
  202. lastDraggedOverElement = draggedOverElement;
  203. }
  204. }
  205. // `drop` event
  206. else if(e.data.msg && e.data.msg === 'drop'){
  207. if(lastDraggedOverElement){
  208. const dropEvent = new CustomEvent('drop', {
  209. bubbles: true,
  210. cancelable: true,
  211. detail: {
  212. clientX: e.data.x,
  213. clientY: e.data.y,
  214. items: e.data.items
  215. }
  216. });
  217. lastDraggedOverElement.dispatchEvent(dropEvent);
  218. // Reset the lastDraggedOverElement
  219. lastDraggedOverElement = null;
  220. }
  221. }
  222. // windowWillClose
  223. else if(e.data.msg === 'windowWillClose'){
  224. // If the user has not overridden onWindowClose() then send a message back to the host environment
  225. // to let it know that it is ok to close the window.
  226. if(this.#onWindowClose === undefined){
  227. this.messageTarget?.postMessage({
  228. msg: true,
  229. appInstanceID: this.appInstanceID,
  230. original_msg_id: e.data.msg_id,
  231. }, '*');
  232. }
  233. // If the user has overridden onWindowClose() then send a message back to the host environment
  234. // to let it know that it is NOT ok to close the window. Then execute onWindowClose() and the user will
  235. // have to manually close the window.
  236. else{
  237. this.messageTarget?.postMessage({
  238. msg: false,
  239. appInstanceID: this.appInstanceID,
  240. original_msg_id: e.data.msg_id,
  241. }, '*');
  242. this.#onWindowClose();
  243. }
  244. }
  245. // itemsOpened
  246. else if(e.data.msg === 'itemsOpened'){
  247. // If the user has not overridden onItemsOpened() then only send a message back to the host environment
  248. if(this.#onItemsOpened === undefined){
  249. this.messageTarget?.postMessage({
  250. msg: true,
  251. appInstanceID: this.appInstanceID,
  252. original_msg_id: e.data.msg_id,
  253. }, '*');
  254. }
  255. // If the user has overridden onItemsOpened() then send a message back to the host environment
  256. // and execute onItemsOpened()
  257. else{
  258. this.messageTarget?.postMessage({
  259. msg: false,
  260. appInstanceID: this.appInstanceID,
  261. original_msg_id: e.data.msg_id,
  262. }, '*');
  263. let items = [];
  264. if(e.data.items.length > 0){
  265. for (let index = 0; index < e.data.items.length; index++)
  266. items.push(new FSItem(e.data.items[index]))
  267. }
  268. this.#onItemsOpened(items);
  269. }
  270. }
  271. // getAppDataSucceeded
  272. else if(e.data.msg === 'getAppDataSucceeded'){
  273. let appDataItem = new FSItem(e.data.item);
  274. if(e.data.original_msg_id && this.#callbackFunctions[e.data.original_msg_id]){
  275. this.#callbackFunctions[e.data.original_msg_id](appDataItem);
  276. }
  277. }
  278. // readAppDataFileSucceeded
  279. else if(e.data.msg === 'readAppDataFileSucceeded'){
  280. let appDataItem = new FSItem(e.data.item);
  281. if(e.data.original_msg_id && this.#callbackFunctions[e.data.original_msg_id]){
  282. this.#callbackFunctions[e.data.original_msg_id](appDataItem);
  283. }
  284. }
  285. // readAppDataFileFailed
  286. else if(e.data.msg === 'readAppDataFileFailed'){
  287. if(e.data.original_msg_id && this.#callbackFunctions[e.data.original_msg_id]){
  288. this.#callbackFunctions[e.data.original_msg_id](null);
  289. }
  290. }
  291. // Determine if this is a response to a previous message and if so, is there
  292. // a callback function for this message? if answer is yes to both then execute the callback
  293. else if(e.data.original_msg_id && this.#callbackFunctions[e.data.original_msg_id]){
  294. if(e.data.msg === 'fileOpenPicked'){
  295. // 1 item returned
  296. if(e.data.items.length === 1){
  297. this.#callbackFunctions[e.data.original_msg_id](new FSItem(e.data.items[0]));
  298. }
  299. // multiple items returned
  300. else if(e.data.items.length > 1){
  301. // multiple items returned
  302. let items = [];
  303. for (let index = 0; index < e.data.items.length; index++)
  304. items.push(new FSItem(e.data.items[index]))
  305. this.#callbackFunctions[e.data.original_msg_id](items);
  306. }
  307. }
  308. else if(e.data.msg === 'directoryPicked'){
  309. // 1 item returned
  310. if(e.data.items.length === 1){
  311. this.#callbackFunctions[e.data.original_msg_id](new FSItem({
  312. uid: e.data.items[0].uid,
  313. name: e.data.items[0].fsentry_name,
  314. path: e.data.items[0].path,
  315. readURL: e.data.items[0].read_url,
  316. writeURL: e.data.items[0].write_url,
  317. metadataURL: e.data.items[0].metadata_url,
  318. isDirectory: true,
  319. size: e.data.items[0].fsentry_size,
  320. accessed: e.data.items[0].fsentry_accessed,
  321. modified: e.data.items[0].fsentry_modified,
  322. created: e.data.items[0].fsentry_created,
  323. }));
  324. }
  325. // multiple items returned
  326. else if(e.data.items.length > 1){
  327. // multiple items returned
  328. let items = [];
  329. for (let index = 0; index < e.data.items.length; index++)
  330. items.push(new FSItem(e.data.items[index]))
  331. this.#callbackFunctions[e.data.original_msg_id](items);
  332. }
  333. }
  334. else if(e.data.msg === 'colorPicked'){
  335. // execute callback
  336. this.#callbackFunctions[e.data.original_msg_id](e.data.color);
  337. }
  338. else if(e.data.msg === 'fontPicked'){
  339. // execute callback
  340. this.#callbackFunctions[e.data.original_msg_id](e.data.font);
  341. }
  342. else if(e.data.msg === 'alertResponded'){
  343. // execute callback
  344. this.#callbackFunctions[e.data.original_msg_id](e.data.response);
  345. }
  346. else if(e.data.msg === 'promptResponded'){
  347. // execute callback
  348. this.#callbackFunctions[e.data.original_msg_id](e.data.response);
  349. }
  350. else if(e.data.msg === "fileSaved"){
  351. // execute callback
  352. this.#callbackFunctions[e.data.original_msg_id](new FSItem(e.data.saved_file));
  353. }
  354. else if (e.data.msg === 'childAppLaunched') {
  355. // execute callback with a new AppConnection to the child
  356. const connection = new AppConnection(this.messageTarget, this.appInstanceID, e.data.child_instance_id, e.data.uses_sdk);
  357. this.#callbackFunctions[e.data.original_msg_id](connection);
  358. }
  359. else{
  360. // execute callback
  361. this.#callbackFunctions[e.data.original_msg_id](e.data);
  362. }
  363. //remove this callback function since it won't be needed again
  364. delete this.#callbackFunctions[e.data.original_msg_id];
  365. }
  366. // Item Watch response
  367. else if(e.data.msg === "itemChanged" && e.data.data && e.data.data.uid){
  368. //excute callback
  369. if(itemWatchCallbackFunctions[e.data.data.uid] && typeof itemWatchCallbackFunctions[e.data.data.uid] === 'function')
  370. itemWatchCallbackFunctions[e.data.data.uid](e.data.data);
  371. }
  372. // Broadcasts
  373. else if (e.data.msg === 'broadcast') {
  374. const { name, data } = e.data;
  375. if (!this.#eventNames.includes(name)) {
  376. return;
  377. }
  378. this.emit(name, data);
  379. this.#lastBroadcastValue.set(name, data);
  380. }
  381. });
  382. }
  383. onWindowClose = function(callback) {
  384. this.#onWindowClose = callback;
  385. }
  386. onItemsOpened = function(callback) {
  387. // DEPRECATED - this is also called when items are dropped on the app, which in new versions should be handled
  388. // with the 'drop' event.
  389. // Check if a file was opened with this app, i.e. check URL parameters of window/iframe
  390. // Even though the file has been opened when the app is launched, we need to wait for the onItemsOpened callback to be set
  391. // before we can call it. This is why we need to check the URL parameters here.
  392. // This should also be done only the very first time the callback is set (hence the if(!this.#onItemsOpened) check) since
  393. // the URL parameters will be checked every time the callback is set which can cause problems if the callback is set multiple times.
  394. if(!this.#onItemsOpened){
  395. let URLParams = new URLSearchParams(window.location.search);
  396. if(URLParams.has('puter.item.name') && URLParams.has('puter.item.uid') && URLParams.has('puter.item.read_url')){
  397. let fpath = URLParams.get('puter.item.path');
  398. fpath = `~/` + fpath.split('/').slice(2).join('/');
  399. callback([new FSItem({
  400. name: URLParams.get('puter.item.name'),
  401. path: fpath,
  402. uid: URLParams.get('puter.item.uid'),
  403. readURL: URLParams.get('puter.item.read_url'),
  404. writeURL: URLParams.get('puter.item.write_url'),
  405. metadataURL: URLParams.get('puter.item.metadata_url'),
  406. size: URLParams.get('puter.item.size'),
  407. accessed: URLParams.get('puter.item.accessed'),
  408. modified: URLParams.get('puter.item.modified'),
  409. created: URLParams.get('puter.item.created'),
  410. })]);
  411. }
  412. }
  413. this.#onItemsOpened = callback;
  414. }
  415. onLaunchedWithItems = function(callback) {
  416. // Check if a file was opened with this app, i.e. check URL parameters of window/iframe
  417. // Even though the file has been opened when the app is launched, we need to wait for the onLaunchedWithItems callback to be set
  418. // before we can call it. This is why we need to check the URL parameters here.
  419. // This should also be done only the very first time the callback is set (hence the if(!this.#onLaunchedWithItems) check) since
  420. // the URL parameters will be checked every time the callback is set which can cause problems if the callback is set multiple times.
  421. if(!this.#onLaunchedWithItems){
  422. let URLParams = new URLSearchParams(window.location.search);
  423. if(URLParams.has('puter.item.name') && URLParams.has('puter.item.uid') && URLParams.has('puter.item.read_url')){
  424. let fpath = URLParams.get('puter.item.path');
  425. fpath = `~/` + fpath.split('/').slice(2).join('/');
  426. callback([new FSItem({
  427. name: URLParams.get('puter.item.name'),
  428. path: fpath,
  429. uid: URLParams.get('puter.item.uid'),
  430. readURL: URLParams.get('puter.item.read_url'),
  431. writeURL: URLParams.get('puter.item.write_url'),
  432. metadataURL: URLParams.get('puter.item.metadata_url'),
  433. size: URLParams.get('puter.item.size'),
  434. accessed: URLParams.get('puter.item.accessed'),
  435. modified: URLParams.get('puter.item.modified'),
  436. created: URLParams.get('puter.item.created'),
  437. })]);
  438. }
  439. }
  440. this.#onLaunchedWithItems = callback;
  441. }
  442. alert = function(message, buttons, options, callback) {
  443. return new Promise((resolve) => {
  444. this.#postMessageWithCallback('ALERT', resolve, { message, buttons, options });
  445. })
  446. }
  447. prompt = function(message, placeholder, options, callback) {
  448. return new Promise((resolve) => {
  449. this.#postMessageWithCallback('PROMPT', resolve, { message, placeholder, options });
  450. })
  451. }
  452. showDirectoryPicker = function(options, callback){
  453. return new Promise((resolve) => {
  454. const msg_id = this.#messageID++;
  455. if(this.env === 'app'){
  456. this.messageTarget?.postMessage({
  457. msg: "showDirectoryPicker",
  458. appInstanceID: this.appInstanceID,
  459. uuid: msg_id,
  460. options: options,
  461. env: this.env,
  462. }, '*');
  463. }else{
  464. let w = 700;
  465. let h = 400;
  466. let title = 'Puter: Open Directory';
  467. var left = (screen.width/2)-(w/2);
  468. var top = (screen.height/2)-(h/2);
  469. window.open(`${puter.defaultGUIOrigin}/action/show-directory-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&options=${JSON.stringify(options)}`,
  470. title,
  471. 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width='+w+', height='+h+', top='+top+', left='+left);
  472. }
  473. //register callback
  474. this.#callbackFunctions[msg_id] = resolve;
  475. })
  476. }
  477. showOpenFilePicker = function(options, callback){
  478. return new Promise((resolve) => {
  479. const msg_id = this.#messageID++;
  480. if(this.env === 'app'){
  481. this.messageTarget?.postMessage({
  482. msg: "showOpenFilePicker",
  483. appInstanceID: this.appInstanceID,
  484. uuid: msg_id,
  485. options: options ?? {},
  486. env: this.env,
  487. }, '*');
  488. }else{
  489. let w = 700;
  490. let h = 400;
  491. let title = 'Puter: Open File';
  492. var left = (screen.width/2)-(w/2);
  493. var top = (screen.height/2)-(h/2);
  494. window.open(`${puter.defaultGUIOrigin}/action/show-open-file-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&options=${JSON.stringify(options ?? {})}`,
  495. title,
  496. 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width='+w+', height='+h+', top='+top+', left='+left);
  497. }
  498. //register callback
  499. this.#callbackFunctions[msg_id] = resolve;
  500. })
  501. }
  502. showFontPicker = function(options){
  503. return new Promise((resolve) => {
  504. this.#postMessageWithCallback('showFontPicker', resolve, { options: options ?? {} });
  505. })
  506. }
  507. showColorPicker = function(options){
  508. return new Promise((resolve) => {
  509. this.#postMessageWithCallback('showColorPicker', resolve, { options: options ?? {} });
  510. })
  511. }
  512. showSaveFilePicker = function(content, suggestedName){
  513. return new Promise((resolve) => {
  514. const msg_id = this.#messageID++;
  515. const url = (Object.prototype.toString.call(content) === '[object URL]' ? content : undefined);
  516. if(this.env === 'app'){
  517. this.messageTarget?.postMessage({
  518. msg: "showSaveFilePicker",
  519. appInstanceID: this.appInstanceID,
  520. content: url ? undefined : content,
  521. url: url ? url.toString() : undefined,
  522. suggestedName: suggestedName ?? '',
  523. env: this.env,
  524. uuid: msg_id
  525. }, '*');
  526. }else{
  527. window.addEventListener('message', async (e) => {
  528. if(e.data?.msg === "sendMeFileData"){
  529. // Send the blob URL to the host environment
  530. e.source.postMessage({
  531. msg: "showSaveFilePickerPopup",
  532. content: url ? undefined : content,
  533. url: url ? url.toString() : undefined,
  534. suggestedName: suggestedName ?? '',
  535. env: this.env,
  536. uuid: msg_id
  537. }, '*');
  538. // remove the event listener
  539. window.removeEventListener('message', this);
  540. }
  541. });
  542. // Create a Blob from your binary data
  543. let blob = new Blob([content], {type: 'application/octet-stream'});
  544. // Create an object URL for the Blob
  545. let objectUrl = URL.createObjectURL(blob);
  546. let w = 700;
  547. let h = 400;
  548. let title = 'Puter: Save File';
  549. var left = (screen.width/2)-(w/2);
  550. var top = (screen.height/2)-(h/2);
  551. window.open(`${puter.defaultGUIOrigin}/action/show-save-file-picker?embedded_in_popup=true&msg_id=${msg_id}&appInstanceID=${this.appInstanceID}&env=${this.env}&blobUrl=${encodeURIComponent(objectUrl)}`,
  552. title,
  553. 'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width='+w+', height='+h+', top='+top+', left='+left);
  554. }
  555. //register callback
  556. this.#callbackFunctions[msg_id] = resolve;
  557. })
  558. }
  559. setWindowTitle = function(title, callback) {
  560. return new Promise((resolve) => {
  561. this.#postMessageWithCallback('setWindowTitle', resolve, { new_title: title });
  562. })
  563. }
  564. setWindowWidth = function(width, callback) {
  565. return new Promise((resolve) => {
  566. this.#postMessageWithCallback('setWindowWidth', resolve, { width });
  567. })
  568. }
  569. setWindowHeight = function(height, callback) {
  570. return new Promise((resolve) => {
  571. this.#postMessageWithCallback('setWindowHeight', resolve, { height });
  572. })
  573. }
  574. setWindowSize = function(width, height, callback) {
  575. return new Promise((resolve) => {
  576. this.#postMessageWithCallback('setWindowSize', resolve, { width, height });
  577. })
  578. }
  579. setWindowPosition = function(x, y, callback) {
  580. return new Promise((resolve) => {
  581. this.#postMessageWithCallback('setWindowPosition', resolve, { x, y });
  582. })
  583. }
  584. /**
  585. * Asynchronously extracts entries from DataTransferItems, like files and directories.
  586. *
  587. * @private
  588. * @function
  589. * @async
  590. * @param {DataTransferItemList} dataTransferItems - List of data transfer items from a drag-and-drop operation.
  591. * @param {Object} [options={}] - Optional settings.
  592. * @param {boolean} [options.raw=false] - Determines if the file path should be processed.
  593. * @returns {Promise<Array<File|Entry>>} - A promise that resolves to an array of File or Entry objects.
  594. * @throws {Error} - Throws an error if there's an EncodingError and provides information about how to solve it.
  595. *
  596. * @example
  597. * const items = event.dataTransfer.items;
  598. * const entries = await getEntriesFromDataTransferItems(items, { raw: false });
  599. */
  600. getEntriesFromDataTransferItems = async function(dataTransferItems, options = { raw: false }) {
  601. const checkErr = (err) => {
  602. if (this.getEntriesFromDataTransferItems.didShowInfo) return
  603. if (err.name !== 'EncodingError') return
  604. this.getEntriesFromDataTransferItems.didShowInfo = true
  605. const infoMsg = `${err.name} occured within datatransfer-files-promise module\n`
  606. + `Error message: "${err.message}"\n`
  607. + 'Try serving html over http if currently you are running it from the filesystem.'
  608. console.warn(infoMsg)
  609. }
  610. const readFile = (entry, path = '') => {
  611. return new Promise((resolve, reject) => {
  612. entry.file(file => {
  613. if (!options.raw) file.filepath = path + file.name // save full path
  614. resolve(file)
  615. }, (err) => {
  616. checkErr(err)
  617. reject(err)
  618. })
  619. })
  620. }
  621. const dirReadEntries = (dirReader, path) => {
  622. return new Promise((resolve, reject) => {
  623. dirReader.readEntries(async entries => {
  624. let files = []
  625. for (let entry of entries) {
  626. const itemFiles = await getFilesFromEntry(entry, path)
  627. files = files.concat(itemFiles)
  628. }
  629. resolve(files)
  630. }, (err) => {
  631. checkErr(err)
  632. reject(err)
  633. })
  634. })
  635. }
  636. const readDir = async (entry, path) => {
  637. const dirReader = entry.createReader()
  638. const newPath = path + entry.name + '/'
  639. let files = []
  640. let newFiles
  641. do {
  642. newFiles = await dirReadEntries(dirReader, newPath)
  643. files = files.concat(newFiles)
  644. } while (newFiles.length > 0)
  645. return files
  646. }
  647. const getFilesFromEntry = async (entry, path = '') => {
  648. if(entry === null)
  649. return;
  650. else if (entry.isFile) {
  651. const file = await readFile(entry, path)
  652. return [file]
  653. }
  654. else if (entry.isDirectory) {
  655. const files = await readDir(entry, path)
  656. files.push(entry)
  657. return files
  658. }
  659. }
  660. let files = []
  661. let entries = []
  662. // Pull out all entries before reading them
  663. for (let i = 0, ii = dataTransferItems.length; i < ii; i++) {
  664. entries.push(dataTransferItems[i].webkitGetAsEntry())
  665. }
  666. // Recursively read through all entries
  667. for (let entry of entries) {
  668. const newFiles = await getFilesFromEntry(entry)
  669. files = files.concat(newFiles)
  670. }
  671. return files
  672. }
  673. authenticateWithPuter = function() {
  674. if(this.env !== 'web'){
  675. return;
  676. }
  677. // if authToken is already present, resolve immediately
  678. if(this.authToken){
  679. return new Promise((resolve) => {
  680. resolve();
  681. })
  682. }
  683. // If a prompt is already open, return a promise that resolves based on the existing prompt's result.
  684. if (puter.puterAuthState.isPromptOpen) {
  685. return new Promise((resolve, reject) => {
  686. puter.puterAuthState.resolver = { resolve, reject };
  687. });
  688. }
  689. // Show the permission prompt and set the state.
  690. puter.puterAuthState.isPromptOpen = true;
  691. puter.puterAuthState.authGranted = null;
  692. return new Promise((resolve, reject) => {
  693. if (!puter.authToken) {
  694. const puterDialog = new PuterDialog(resolve, reject);
  695. document.body.appendChild(puterDialog);
  696. puterDialog.open();
  697. } else {
  698. // If authToken is already present, resolve immediately
  699. resolve();
  700. }
  701. });
  702. }
  703. // Returns a Promise<AppConnection>
  704. launchApp = function(appName, args, callback) {
  705. return new Promise((resolve) => {
  706. // if appName is an object and args is not set, then appName is actually args
  707. if (typeof appName === 'object' && !args) {
  708. args = appName;
  709. appName = undefined;
  710. }
  711. this.#postMessageWithCallback('launchApp', resolve, { app_name: appName, args });
  712. })
  713. }
  714. parentApp() {
  715. return this.#parentAppConnection;
  716. }
  717. createWindow = function (options, callback) {
  718. return new Promise((resolve) => {
  719. this.#postMessageWithCallback('createWindow', resolve, { options: options ?? {} });
  720. })
  721. }
  722. // Menubar
  723. menubar = function(){
  724. // Remove previous style tag
  725. document.querySelectorAll('style.puter-stylesheet').forEach(function(el) {
  726. el.remove();
  727. })
  728. // Add new style tag
  729. const style = document.createElement('style');
  730. style.classList.add('puter-stylesheet');
  731. style.innerHTML = `
  732. .--puter-menubar {
  733. border-bottom: 1px solid #e9e9e9;
  734. background-color: #fbf9f9;
  735. padding-top: 3px;
  736. padding-bottom: 2px;
  737. display: inline-block;
  738. position: fixed;
  739. top: 0;
  740. width: 100%;
  741. margin: 0;
  742. padding: 0;
  743. height: 31px;
  744. font-family: Arial, Helvetica, sans-serif;
  745. font-size: 13px;
  746. z-index: 9999;
  747. }
  748. .--puter-menubar, .--puter-menubar * {
  749. user-select: none;
  750. -webkit-user-select: none;
  751. cursor: default;
  752. }
  753. .--puter-menubar .dropdown-item-divider>hr {
  754. margin-top: 5px;
  755. margin-bottom: 5px;
  756. border-bottom: none;
  757. border-top: 1px solid #00000033;
  758. }
  759. .--puter-menubar>li {
  760. display: inline-block;
  761. padding: 10px 5px;
  762. }
  763. .--puter-menubar>li>ul {
  764. display: none;
  765. z-index: 999999999999;
  766. list-style: none;
  767. background-color: rgb(233, 233, 233);
  768. width: 200px;
  769. border: 1px solid #e4ebf3de;
  770. box-shadow: 0px 0px 5px #00000066;
  771. padding-left: 6px;
  772. padding-right: 6px;
  773. padding-top: 4px;
  774. padding-bottom: 4px;
  775. color: #333;
  776. border-radius: 4px;
  777. padding: 2px;
  778. min-width: 200px;
  779. margin-top: 5px;
  780. position: absolute;
  781. }
  782. .--puter-menubar .menubar-item {
  783. display: block;
  784. line-height: 24px;
  785. margin-top: -7px;
  786. text-align: center;
  787. border-radius: 3px;
  788. padding: 0 5px;
  789. }
  790. .--puter-menubar .menubar-item-open {
  791. background-color: rgb(216, 216, 216);
  792. }
  793. .--puter-menubar .dropdown-item {
  794. padding: 5px;
  795. padding: 5px 30px;
  796. list-style-type: none;
  797. user-select: none;
  798. font-size: 13px;
  799. }
  800. .--puter-menubar .dropdown-item-icon, .--puter-menubar .dropdown-item-icon-active {
  801. pointer-events: none;
  802. width: 18px;
  803. height: 18px;
  804. margin-left: -23px;
  805. margin-bottom: -4px;
  806. margin-right: 5px;
  807. }
  808. .--puter-menubar .dropdown-item-disabled .dropdown-item-icon{
  809. display: inline-block !important;
  810. }
  811. .--puter-menubar .dropdown-item-disabled .dropdown-item-icon-active{
  812. display: none !important;
  813. }
  814. .--puter-menubar .dropdown-item-icon-active {
  815. display:none;
  816. }
  817. .--puter-menubar .dropdown-item:hover .dropdown-item-icon{
  818. display: none;
  819. }
  820. .--puter-menubar .dropdown-item:hover .dropdown-item-icon-active{
  821. display: inline-block;
  822. }
  823. .--puter-menubar .dropdown-item-hide-icon .dropdown-item-icon, .--puter-menubar .dropdown-item-hide-icon .dropdown-item-icon-active{
  824. display: none !important;
  825. }
  826. .--puter-menubar .dropdown-item a {
  827. color: #333;
  828. text-decoration: none;
  829. }
  830. .--puter-menubar .dropdown-item:hover, .--puter-menubar .dropdown-item:hover a {
  831. background-color: rgb(59 134 226);
  832. color: white;
  833. border-radius: 4px;
  834. }
  835. .--puter-menubar .dropdown-item-disabled, .--puter-menubar .dropdown-item-disabled:hover {
  836. opacity: 0.5;
  837. background-color: transparent;
  838. color: initial;
  839. cursor: initial;
  840. pointer-events: none;
  841. }
  842. .--puter-menubar .menubar * {
  843. user-select: none;
  844. }
  845. `;
  846. let head = document.head || document.getElementsByTagName('head')[0];
  847. head.appendChild(style);
  848. document.addEventListener('click', function(e){
  849. // Don't hide if clicking on disabled item
  850. if(e.target.classList.contains('dropdown-item-disabled'))
  851. return false;
  852. // Hide open menus
  853. if(!(e.target).classList.contains('menubar-item')){
  854. document.querySelectorAll('.menubar-item.menubar-item-open').forEach(function(el) {
  855. el.classList.remove('menubar-item-open');
  856. })
  857. document.querySelectorAll('.dropdown').forEach(el => el.style.display = "none");
  858. }
  859. });
  860. // When focus is gone from this window, hide open menus
  861. window.addEventListener('blur', function(e){
  862. document.querySelectorAll('.dropdown').forEach(function(el) {
  863. el.style.display = "none";
  864. })
  865. document.querySelectorAll('.menubar-item.menubar-item-open').forEach(el => el.classList.remove('menubar-item-open'));
  866. });
  867. // Returns the siblings of the element
  868. const siblings = function (e) {
  869. const siblings = [];
  870. // if no parent, return empty list
  871. if(!e.parentNode) {
  872. return siblings;
  873. }
  874. // first child of the parent node
  875. let sibling = e.parentNode.firstChild;
  876. // get all other siblings
  877. while (sibling) {
  878. if (sibling.nodeType === 1 && sibling !== e) {
  879. siblings.push(sibling);
  880. }
  881. sibling = sibling.nextSibling;
  882. }
  883. return siblings;
  884. };
  885. // Open dropdown
  886. document.querySelectorAll('.menubar-item').forEach(el => el.addEventListener('mousedown', function(e){
  887. // Hide all other menus
  888. document.querySelectorAll('.dropdown').forEach(function(el) {
  889. el.style.display = 'none';
  890. });
  891. // Remove open class from all menus, except this menu that was just clicked
  892. document.querySelectorAll('.menubar-item.menubar-item-open').forEach(function(el) {
  893. if(el != e.target)
  894. el.classList.remove('menubar-item-open');
  895. });
  896. // If menu is already open, close it
  897. if(this.classList.contains('menubar-item-open')){
  898. document.querySelectorAll('.menubar-item.menubar-item-open').forEach(function(el) {
  899. el.classList.remove('menubar-item-open');
  900. });
  901. }
  902. // If menu is not open, open it
  903. else if(!e.target.classList.contains('dropdown-item')){
  904. this.classList.add('menubar-item-open')
  905. // show all sibling
  906. siblings(this).forEach(function(el) {
  907. el.style.display = 'block';
  908. });
  909. }
  910. }));
  911. // If a menu is open, and you hover over another menu, open that menu
  912. document.querySelectorAll('.--puter-menubar .menubar-item').forEach(el => el.addEventListener('mouseover', function(e){
  913. const open_menus = document.querySelectorAll('.menubar-item.menubar-item-open');
  914. if(open_menus.length > 0 && open_menus[0] !== e.target){
  915. e.target.dispatchEvent(new Event('mousedown'));
  916. }
  917. }))
  918. }
  919. on(eventName, callback) {
  920. super.on(eventName, callback);
  921. // If we already received a broadcast for this event, run the callback immediately
  922. if (this.#eventNames.includes(eventName) && this.#lastBroadcastValue.has(eventName)) {
  923. callback(this.#lastBroadcastValue.get(eventName));
  924. }
  925. }
  926. }
  927. export default UI