├── v2 ├── chrome │ ├── data │ ├── _locales │ ├── safe.js │ ├── background.js │ └── manifest.json └── firefox │ ├── data │ ├── editor │ │ ├── vendor │ │ │ └── README │ │ ├── trash.svg │ │ ├── drag.svg │ │ ├── index.html │ │ ├── index.js │ │ └── index.css │ ├── icons │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 19.png │ │ ├── 256.png │ │ ├── 32.png │ │ ├── 38.png │ │ ├── 48.png │ │ └── 64.png │ ├── discard │ │ ├── icon.png │ │ ├── index.html │ │ └── index.js │ ├── popup │ │ ├── locked.svg │ │ ├── unlock.svg │ │ ├── remove.svg │ │ ├── overwrite.svg │ │ ├── preview.svg │ │ ├── index.html │ │ ├── index.css │ │ └── index.js │ ├── drop │ │ ├── index.html │ │ └── index.js │ └── dialog │ │ ├── index.js │ │ └── index.html │ ├── _locales │ ├── es │ │ └── messages.json │ ├── fr │ │ └── messages.json │ ├── pt_BR │ │ └── messages.json │ ├── ru │ │ └── messages.json │ ├── de │ │ └── messages.json │ ├── it │ │ └── messages.json │ ├── nl │ │ └── messages.json │ └── en │ │ └── messages.json │ ├── manifest.json │ ├── safe.js │ └── background.js ├── v3 ├── data │ ├── editor │ │ ├── vendor │ │ │ └── README │ │ ├── trash.svg │ │ ├── drag.svg │ │ ├── index.html │ │ ├── index.js │ │ └── index.css │ ├── icons │ │ ├── 128.png │ │ ├── 16.png │ │ ├── 19.png │ │ ├── 256.png │ │ ├── 32.png │ │ ├── 38.png │ │ ├── 48.png │ │ └── 64.png │ ├── discard │ │ ├── icon.png │ │ ├── index.html │ │ ├── index.css │ │ └── index.js │ ├── popup │ │ ├── locked.svg │ │ ├── unlock.svg │ │ ├── remove.svg │ │ ├── overwrite.svg │ │ ├── rename.svg │ │ ├── preview.svg │ │ ├── index.html │ │ ├── index.css │ │ └── index.js │ ├── drop │ │ ├── index.html │ │ └── index.js │ └── dialog │ │ ├── index.html │ │ ├── index.css │ │ └── index.js ├── _locales │ ├── es │ │ └── messages.json │ ├── fr │ │ └── messages.json │ ├── ru │ │ └── messages.json │ ├── pt_BR │ │ └── messages.json │ ├── de │ │ └── messages.json │ ├── it │ │ └── messages.json │ ├── nl │ │ └── messages.json │ └── en │ │ └── messages.json ├── manifest.json ├── safe.js └── worker.js ├── README.md ├── .github └── FUNDING.yml └── LICENSE /v2/chrome/data: -------------------------------------------------------------------------------- 1 | ../firefox/data -------------------------------------------------------------------------------- /v2/chrome/_locales: -------------------------------------------------------------------------------- 1 | ../firefox/_locales -------------------------------------------------------------------------------- /v2/chrome/safe.js: -------------------------------------------------------------------------------- 1 | ../firefox/safe.js -------------------------------------------------------------------------------- /v2/chrome/background.js: -------------------------------------------------------------------------------- 1 | ../firefox/background.js -------------------------------------------------------------------------------- /v3/data/editor/vendor/README: -------------------------------------------------------------------------------- 1 | https://github.com/SortableJS/Sortable/archive/1.10.2.zip 2 | -------------------------------------------------------------------------------- /v2/firefox/data/editor/vendor/README: -------------------------------------------------------------------------------- 1 | https://github.com/SortableJS/Sortable/archive/1.10.2.zip 2 | -------------------------------------------------------------------------------- /v3/data/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v3/data/icons/128.png -------------------------------------------------------------------------------- /v3/data/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v3/data/icons/16.png -------------------------------------------------------------------------------- /v3/data/icons/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v3/data/icons/19.png -------------------------------------------------------------------------------- /v3/data/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v3/data/icons/256.png -------------------------------------------------------------------------------- /v3/data/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v3/data/icons/32.png -------------------------------------------------------------------------------- /v3/data/icons/38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v3/data/icons/38.png -------------------------------------------------------------------------------- /v3/data/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v3/data/icons/48.png -------------------------------------------------------------------------------- /v3/data/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v3/data/icons/64.png -------------------------------------------------------------------------------- /v3/data/discard/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v3/data/discard/icon.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v2/firefox/data/icons/128.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v2/firefox/data/icons/16.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v2/firefox/data/icons/19.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v2/firefox/data/icons/256.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v2/firefox/data/icons/32.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v2/firefox/data/icons/38.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v2/firefox/data/icons/48.png -------------------------------------------------------------------------------- /v2/firefox/data/icons/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v2/firefox/data/icons/64.png -------------------------------------------------------------------------------- /v2/firefox/data/discard/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Emano-Waldeck/Save-Tabs/HEAD/v2/firefox/data/discard/icon.png -------------------------------------------------------------------------------- /v3/_locales/es/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Una extensión del protector de pestañas protegido por contraseña para almacenar pestañas" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/fr/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Une extension de l'économiseur d'onglets protégé par un mot de passe pour stocker les onglets" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/es/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Una extensión del protector de pestañas protegido por contraseña para almacenar pestañas" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Расширение защищенной паролем заставки вкладок для хранения вкладок в текущем окне или во всех окнах" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/fr/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Une extension de l'économiseur d'onglets protégé par un mot de passe pour stocker les onglets" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/pt_BR/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Uma extensão de aba protegida por senha para armazenar abas na janela atual, ou em todas as janelas" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/pt_BR/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Uma extensão de aba protegida por senha para armazenar abas na janela atual, ou em todas as janelas" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/ru/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Расширение защищенной паролем заставки вкладок для хранения вкладок в текущем окне или во всех окнах" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/de/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Eine passwortgeschützte Tab-Saver-Erweiterung zum Speichern von Tabs im aktuellen Fenster oder in allen Fenstern" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/de/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Eine passwortgeschützte Tab-Saver-Erweiterung zum Speichern von Tabs im aktuellen Fenster oder in allen Fenstern" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/it/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Un'estensione salvascheda protetta da password per memorizzare le schede nella finestra corrente, o in tutte le finestre" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/nl/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Een met een wachtwoord beveiligde tabbladbeveiliging om tabbladen in het huidige venster of in alle vensters op te slaan." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/it/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Un'estensione salvascheda protetta da password per memorizzare le schede nella finestra corrente, o in tutte le finestre" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v2/firefox/_locales/nl/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "Een met een wachtwoord beveiligde tabbladbeveiliging om tabbladen in het huidige venster of in alle vensters op te slaan." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /v3/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "A password-protected tab saver and session manager to store tabs in the current window, or all windows with tab grouping support" 4 | } 5 | } 6 | 7 | -------------------------------------------------------------------------------- /v2/firefox/_locales/en/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": { 3 | "message": "A password-protected tab saver and session manager to store tabs in the current window, or all windows with tab grouping support" 4 | } 5 | } 6 | 7 | -------------------------------------------------------------------------------- /v3/data/editor/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /v2/firefox/data/discard/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /v2/firefox/data/editor/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /v3/data/editor/drag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /v2/firefox/data/editor/drag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /v3/data/discard/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /v3/data/popup/locked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /v3/data/popup/unlock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /v2/firefox/data/popup/locked.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /v2/firefox/data/popup/unlock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /v3/data/popup/remove.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /v2/firefox/data/popup/remove.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /v3/data/discard/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: light-dark(#fff, #35363a); 3 | --clr: light-dark(#4d5156, #b1b1b1); 4 | 5 | color-scheme: light dark; 6 | } 7 | 8 | html { 9 | height: 100%; 10 | } 11 | body { 12 | font-family: "Helvetica Neue",Helvetica,sans-serif; 13 | font-size: 13px; 14 | color: var(--clr); 15 | background-color: var(--bg); 16 | } 17 | -------------------------------------------------------------------------------- /v3/data/popup/overwrite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /v2/firefox/data/popup/overwrite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /v3/data/popup/rename.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### YouTube Preview 2 | [![Preview](https://img.youtube.com/vi/9EjeHdj43es/0.jpg)](https://www.youtube.com/watch?v=9EjeHdj43es) 3 | 4 | 5 | ### Links 6 | 7 | * FAQs Page: https://add0n.com/save-tabs.html 8 | * Chrome Store: https://chrome.google.com/webstore/detail/save-tabs-browser-session/bmpgbfhajjdffipnaonemjdeifamndod 9 | * Edge Add-ons: https://microsoftedge.microsoft.com/addons/detail/save-tabs-browser-sessi/nbcgofangcmpekchfhhmlnopmdijhbjb 10 | * Firefox Add-ons: https://addons.mozilla.org/en-GB/firefox/addon/save-browser-tabs/ 11 | 12 | -------------------------------------------------------------------------------- /v3/data/discard/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const args = new URLSearchParams(location.search); 4 | 5 | if (args.has('href')) { 6 | const href = args.get('href'); 7 | document.title = args.get('title') || href; 8 | if (document.hidden) { 9 | document.addEventListener('visibilitychange', () => location.replace(href)); 10 | document.querySelector('link[rel="icon"]').href = 11 | `chrome-extension://${chrome.runtime.id}/_favicon/?pageUrl=${encodeURIComponent(href)}&size=32`; 12 | } 13 | else { 14 | location.replace(href); 15 | } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /v2/firefox/data/discard/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const args = new URLSearchParams(location.search); 4 | 5 | 6 | if (args.has('href')) { 7 | const href = args.get('href'); 8 | document.title = args.get('title') || href; 9 | if (document.hidden) { 10 | document.addEventListener('visibilitychange', () => { 11 | location.replace(href); 12 | }); 13 | if (/Firefox/.test(navigator.userAgent) === false) { 14 | document.querySelector('link[rel="icon"]').href = 'chrome://favicon/' + href; 15 | } 16 | } 17 | else { 18 | location.replace(href); 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /v2/firefox/data/drop/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Drop a JSON 5 | 6 | 28 | 29 | 30 | Drop a Session JSON file into this View 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /v2/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "version": "0.2.0", 4 | "name": "Save Tabs - Browser Session Manager", 5 | "description": "__MSG_description__", 6 | "default_locale": "en", 7 | "icons": { 8 | "16": "data/icons/16.png", 9 | "19": "data/icons/19.png", 10 | "32": "data/icons/32.png", 11 | "38": "data/icons/38.png", 12 | "48": "data/icons/48.png", 13 | "64": "data/icons/64.png", 14 | "128": "data/icons/128.png", 15 | "256": "data/icons/256.png" 16 | }, 17 | "permissions": [ 18 | "tabs", 19 | "storage", 20 | "notifications", 21 | "contextMenus", 22 | "chrome://favicon/" 23 | ], 24 | "background": { 25 | "persistent": false, 26 | "scripts": [ 27 | "safe.js", 28 | "background.js" 29 | ] 30 | }, 31 | "browser_action": { 32 | "default_popup": "data/popup/index.html" 33 | }, 34 | "homepage_url": "https://add0n.com/save-tabs.html" 35 | } 36 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: webextension 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /v3/data/drop/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Drop a JSON 5 | 6 | 29 | 30 | 31 | Drop a Session JSON file into this View 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /v3/data/editor/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Manager 5 | 6 | 7 | 8 | 9 | 17 |
18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /v2/firefox/data/editor/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Manager 5 | 6 | 7 | 8 | 9 | 17 |
18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /v3/data/popup/preview.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /v2/firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "version": "0.2.0", 4 | "name": "Save Tabs - Browser Session Manager", 5 | "description": "__MSG_description__", 6 | "default_locale": "en", 7 | "icons": { 8 | "16": "data/icons/16.png", 9 | "19": "data/icons/19.png", 10 | "32": "data/icons/32.png", 11 | "38": "data/icons/38.png", 12 | "48": "data/icons/48.png", 13 | "64": "data/icons/64.png", 14 | "128": "data/icons/128.png", 15 | "256": "data/icons/256.png" 16 | }, 17 | "permissions": [ 18 | "tabs", 19 | "storage", 20 | "notifications", 21 | "contextMenus", 22 | "cookies", 23 | "contextualIdentities" 24 | ], 25 | "background": { 26 | "persistent": false, 27 | "scripts": [ 28 | "safe.js", 29 | "background.js" 30 | ] 31 | }, 32 | "browser_action": { 33 | "default_popup": "data/popup/index.html" 34 | }, 35 | "sidebar_action": { 36 | "default_panel": "data/popup/index.html?mode=vertical" 37 | }, 38 | "homepage_url": "https://add0n.com/save-tabs.html", 39 | "browser_specific_settings": { 40 | "gecko": { 41 | "id": "{08762c8b-d37d-4230-bfdb-a8477be47c34}" 42 | } 43 | }, 44 | "commands": { 45 | "_execute_browser_action": {}, 46 | "_execute_sidebar_action": {} 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /v2/firefox/data/popup/preview.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /v2/firefox/data/drop/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const args = new URLSearchParams(location.search); 4 | 5 | document.addEventListener('dragover', e => e.preventDefault()); 6 | 7 | const read = file => { 8 | if (file.size > 100e6) { 9 | console.warn('more than 100MB session file? I don\'t believe you.'); 10 | return; 11 | } 12 | const reader = new FileReader(); 13 | reader.onloadend = event => { 14 | const json = JSON.parse(event.target.result); 15 | const next = () => chrome.runtime.reload(); 16 | chrome.storage.sync.get(null, prefs => { 17 | if (args.get('command') === 'append') { 18 | const sessions = [...(prefs.sessions || []), ...(json.sessions || [])] 19 | .filter((s, i, l) => s && l.indexOf(s) === i); 20 | Object.assign(prefs, json); 21 | prefs.sessions = sessions; 22 | chrome.storage.sync.set(prefs, next); 23 | } 24 | else if (args.get('command') === 'overwrite') { 25 | chrome.storage.sync.clear(() => chrome.storage.sync.set(json, next)); 26 | } 27 | }); 28 | }; 29 | reader.readAsText(file, 'utf-8'); 30 | }; 31 | 32 | document.addEventListener('drop', e => { 33 | e.preventDefault(); 34 | read(e.dataTransfer.files[0]); 35 | }); 36 | 37 | document.querySelector('input').addEventListener('change', e => { 38 | read(e.target.files[0]); 39 | }); 40 | -------------------------------------------------------------------------------- /v3/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "version": "0.3.5", 4 | "name": "Save Tabs - Browser Session Manager", 5 | "description": "__MSG_description__", 6 | "default_locale": "en", 7 | "icons": { 8 | "16": "/data/icons/16.png", 9 | "19": "/data/icons/19.png", 10 | "32": "/data/icons/32.png", 11 | "38": "/data/icons/38.png", 12 | "48": "/data/icons/48.png", 13 | "64": "/data/icons/64.png", 14 | "128": "/data/icons/128.png", 15 | "256": "/data/icons/256.png" 16 | }, 17 | "permissions": [ 18 | "tabs", 19 | "storage", 20 | "unlimitedStorage", 21 | "notifications", 22 | "contextMenus", 23 | "tabGroups", 24 | "downloads", 25 | "favicon" 26 | ], 27 | "background": { 28 | "service_worker": "worker.js", 29 | "scripts": ["safe.js", "worker.js"] 30 | }, 31 | "action": { 32 | "default_popup": "/data/popup/index.html" 33 | }, 34 | "homepage_url": "https://add0n.com/save-tabs.html", 35 | "commands": { 36 | "_execute_action": {} 37 | }, 38 | "browser_specific_settings": { 39 | "gecko": { 40 | "id": "{08762c8b-d37d-4230-bfdb-a8477be47c34}", 41 | "strict_min_version": "139.0", 42 | "data_collection_permissions": { 43 | "required": ["none"] 44 | }, 45 | "optional_permissions": [ 46 | "cookies" 47 | ] 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /v3/data/dialog/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Save Tabs 7 | 8 | 9 | 10 |
11 | Session name 12 | 13 | Password 14 | 15 | Repeat Password 16 | 17 |
18 | Internal Tabs 19 | 20 |
21 | Pinned Tabs 22 | 23 | Permanent 24 | 25 | 26 |
27 | 28 | 29 |
30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /v2/firefox/safe.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var safe = {}; 4 | 5 | { 6 | const toBuffer = str => { 7 | const bytes = new Uint8Array(str.length); 8 | [...str].forEach((c, i) => bytes[i] = c.charCodeAt(0)); 9 | return bytes; 10 | }; 11 | const toString = buffer => [...buffer].map(b => String.fromCharCode(b)).join(''); 12 | 13 | const passwordToKey = password => crypto.subtle.digest({ 14 | name: 'SHA-256' 15 | }, toBuffer(password)).then(result => crypto.subtle.importKey('raw', result, { 16 | name: 'AES-CBC' 17 | }, false, ['encrypt', 'decrypt'])); 18 | 19 | safe.encrypt = async (data, password) => { 20 | const iv = crypto.getRandomValues(new Uint8Array(16)); 21 | const key = await passwordToKey(password); 22 | const result = await crypto.subtle.encrypt({ 23 | name: 'AES-CBC', 24 | iv 25 | }, key, toBuffer(data)); 26 | return new Promise(resolve => { 27 | const reader = new FileReader(); 28 | reader.onload = () => resolve(reader.result); 29 | reader.readAsDataURL(new Blob([iv, result], {type: 'application/octet-binary'})); 30 | }); 31 | }; 32 | safe.decrypt = async (data, password) => { 33 | const iv = crypto.getRandomValues(new Uint8Array(16)); 34 | const key = await passwordToKey(password); 35 | const result = await crypto.subtle.decrypt({ 36 | name: 'AES-CBC', 37 | iv 38 | }, key, toBuffer(atob(data.split(',')[1]))); 39 | return toString((new Uint8Array(result)).subarray(16)); 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /v2/firefox/data/editor/index.js: -------------------------------------------------------------------------------- 1 | /* global Sortable */ 2 | 'use strict'; 3 | 4 | const drag = () => { 5 | Sortable.create(document.getElementById('container'), { 6 | animation: 150, 7 | ghostClass: 'blue', 8 | handle: '.handle' 9 | }); 10 | }; 11 | 12 | document.getElementById('cancel').addEventListener('click', () => { 13 | const iframe = window.top.document.querySelector('iframe'); 14 | iframe.dataset.visible = false; 15 | iframe.src = ''; 16 | delete iframe.onload; 17 | }); 18 | 19 | document.querySelector('form').addEventListener('submit', e => { 20 | e.preventDefault(); 21 | const container = document.getElementById('container'); 22 | const {session, password} = container; 23 | 24 | const tabs = [...document.querySelectorAll('#container .grid')].map((e, index) => ({ 25 | ...e.tab, 26 | title: e.querySelector('input[type=text]:first-of-type').value, 27 | url: e.querySelector('input[type=text]:last-of-type').value, 28 | index 29 | })); 30 | chrome.runtime.sendMessage({ 31 | method: 'update', 32 | session, 33 | password, 34 | tabs 35 | }, () => { 36 | document.getElementById('cancel').click(); 37 | }); 38 | }); 39 | 40 | window.build = ({tabs, session, password}) => { 41 | const container = document.getElementById('container'); 42 | const t = document.querySelector('template'); 43 | for (const tab of tabs) { 44 | const clone = document.importNode(t.content, true); 45 | const [title, url] = [...clone.querySelectorAll('input[type=text]')]; 46 | title.value = tab.title; 47 | url.value = tab.url; 48 | clone.querySelector('div').tab = tab; 49 | 50 | container.appendChild(clone); 51 | } 52 | drag(); 53 | Object.assign(container, {session, password}); 54 | }; 55 | 56 | document.getElementById('container').addEventListener('click', e => { 57 | const command = e.target.dataset.command; 58 | if (command === 'remove') { 59 | e.target.closest('.grid').remove(); 60 | } 61 | else if (e.detail === 2 && command === 'open') { 62 | chrome.tabs.create({ 63 | url: e.target.value 64 | }); 65 | } 66 | }); 67 | -------------------------------------------------------------------------------- /v3/data/drop/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const args = new URLSearchParams(location.search); 4 | 5 | document.addEventListener('dragover', e => e.preventDefault()); 6 | 7 | const read = file => { 8 | if (file.size > 100e6) { 9 | console.warn('more than 100MB session file? I don\'t believe you.'); 10 | return; 11 | } 12 | const reader = new FileReader(); 13 | reader.onloadend = async event => { 14 | try { 15 | const json = JSON.parse(event.target.result); 16 | 17 | const lprefs = await chrome.storage.local.get({ 18 | sessions: [] 19 | }); 20 | const sprefs = await chrome.storage.sync.get({ 21 | sessions: [] 22 | }); 23 | if (args.get('command') === 'overwrite') { 24 | await chrome.storage.sync.clear(); 25 | sprefs.sessions.length = 0; 26 | 27 | await chrome.storage.local.remove('sessions'); 28 | for (const session of lprefs.sessions || []) { 29 | await chrome.storage.local.remove(session); 30 | } 31 | } 32 | for (const session of json.sessions || []) { 33 | try { 34 | await chrome.storage.sync.set({ 35 | [session]: json[session] 36 | }); 37 | if (sprefs.sessions.includes(session) === false) { 38 | sprefs.sessions.push(session); 39 | } 40 | } 41 | catch (e) { 42 | await chrome.storage.local.set({ 43 | [session]: json[session] 44 | }); 45 | if (lprefs.sessions.includes(session) === false) { 46 | lprefs.sessions.push(session); 47 | } 48 | } 49 | } 50 | await chrome.storage.sync.set({ 51 | sessions: sprefs.sessions 52 | }); 53 | await chrome.storage.local.set({ 54 | sessions: lprefs.sessions 55 | }); 56 | chrome.runtime.reload(); 57 | } 58 | catch (e) { 59 | console.error(e); 60 | alert(e.message); 61 | } 62 | }; 63 | reader.readAsText(file, 'utf-8'); 64 | }; 65 | 66 | document.addEventListener('drop', e => { 67 | e.preventDefault(); 68 | read(e.dataTransfer.files[0]); 69 | }); 70 | 71 | document.querySelector('input').addEventListener('change', e => { 72 | read(e.target.files[0]); 73 | }); 74 | -------------------------------------------------------------------------------- /v3/data/dialog/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: light-dark(#fff, #35363a); 3 | --bg-alt: light-dark(#cbccd2, #4e4e4e); 4 | --clr: light-dark(#4d5156, #b1b1b1); 5 | --dark: light-dark(#d9d9d9, #202124); 6 | --button: light-dark(#f0f0f0, #202124); 7 | --link: light-dark(#1c73ff, #86b5ff); 8 | --border: light-dark(#f1f1f1, #84868e); 9 | --opacity: 0.2; 10 | --invert: 0; 11 | 12 | color-scheme: light dark; 13 | } 14 | 15 | html { 16 | height: 100%; 17 | } 18 | body { 19 | font-family: "Helvetica Neue",Helvetica,sans-serif; 20 | font-size: 13px; 21 | margin: 10px; 22 | height: calc(100% - 20px); 23 | color: var(--clr); 24 | background-color: var(--bg); 25 | } 26 | form { 27 | box-sizing: border-box; 28 | display: grid; 29 | grid-template-columns: min-content 1fr; 30 | grid-template-rows: repeat(6, min-content) 1fr; 31 | white-space: nowrap; 32 | grid-gap: 10px 5px; 33 | height: 100%; 34 | overflow: hidden; 35 | } 36 | 37 | @media screen and (max-width: 400px) { 38 | form { 39 | grid-template-columns: 1fr; 40 | } 41 | form span { 42 | display: none; 43 | } 44 | } 45 | 46 | input[type=text], 47 | input[type=password] { 48 | outline: none; 49 | text-indent: 2px; 50 | border: none; 51 | padding: 5px; 52 | background-color: var(--bg-alt); 53 | } 54 | input[type=submit], 55 | input[type=button] { 56 | border: none; 57 | padding: 5px 20px; 58 | background: var(--button); 59 | cursor: pointer; 60 | } 61 | label { 62 | overflow: hidden; 63 | text-overflow: ellipsis; 64 | } 65 | #tools { 66 | display: flex; 67 | justify-content: flex-end; 68 | align-items: flex-end; 69 | grid-column-start: 1; 70 | grid-column-end: 3; 71 | } 72 | #tools > :last-child { 73 | margin-left: 5px; 74 | } 75 | 76 | @media screen and (max-width: 400px) { 77 | #tools { 78 | display: grid; 79 | grid-template-columns: 1fr 1fr; 80 | grid-column-start: unset; 81 | grid-column-end: unset; 82 | justify-content: unset; 83 | } 84 | } 85 | 86 | input[name="name"] { 87 | margin-bottom: 10px; 88 | } 89 | #internal-container { 90 | display: contents; 91 | } 92 | 93 | @supports (-moz-appearance: none) { 94 | #internal-container * { 95 | opacity: 0.5; 96 | pointer-events: none; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /v2/firefox/data/editor/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: 13px; 3 | font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; 4 | margin: 0; 5 | } 6 | form { 7 | height: 100vh; 8 | display: flex; 9 | flex-direction: column; 10 | } 11 | #container { 12 | flex: 1; 13 | } 14 | #tools { 15 | display: grid; 16 | grid-template-columns: min-content min-content; 17 | direction: rtl; 18 | grid-gap: 5px; 19 | padding: 10px; 20 | } 21 | #tools input { 22 | border: none; 23 | background: #f1f1f1; 24 | padding: 10px 20px; 25 | color: #000; 26 | cursor: pointer; 27 | } 28 | .grid { 29 | display: grid; 30 | grid-template-columns: 32px 1fr 1fr 32px; 31 | white-space: nowrap; 32 | border: solid 1px #f1f1f1; 33 | margin-bottom: -1px; 34 | } 35 | @media screen and (max-width: 400px) { 36 | .grid { 37 | grid-template-columns: 32px 1fr 32px; 38 | } 39 | .grid > input { 40 | grid-column-start: 2; 41 | grid-column-end: 3; 42 | } 43 | .grid > span { 44 | grid-row-start: 1; 45 | grid-row-end: 3; 46 | } 47 | .grid > span:first-child { 48 | grid-column-start: 1; 49 | } 50 | .grid > span:last-child { 51 | grid-column-start: 3; 52 | } 53 | } 54 | .grid > span:first-child, 55 | .grid > span:last-child { 56 | background-position: center center; 57 | background-repeat: no-repeat; 58 | opacity: 0.2; 59 | } 60 | .grid > span:first-child { 61 | cursor: ns-resize; 62 | background-image: url('drag.svg'); 63 | } 64 | .grid > span:last-child { 65 | cursor: pointer; 66 | background-image: url('trash.svg'); 67 | } 68 | .grid:hover > span:first-child, 69 | .grid:hover > span:last-child, 70 | .grid.blue > span:first-child, 71 | .grid.blue > span:last-child { 72 | opacity: 1; 73 | } 74 | .grid.blue { 75 | background-color: #00cdff47; 76 | } 77 | .grid input[type=text] { 78 | border: none; 79 | font-size: inherit; 80 | outline: none; 81 | padding: 10px; 82 | overflow: hidden; 83 | background-color: transparent; 84 | } 85 | @media screen and (max-width: 400px) { 86 | .grid input[type=text] { 87 | padding: 10px 2px; 88 | } 89 | } 90 | .grid input[type=text]:focus { 91 | background-color: #f1f1f1; 92 | } 93 | .grid input[type=text]:nth-child(3) { 94 | color: #0064bd; 95 | cursor: pointer; 96 | } 97 | -------------------------------------------------------------------------------- /v3/safe.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class Safe { 4 | #key = ''; 5 | 6 | #encoder = new TextEncoder(); 7 | #decoder = new TextDecoder(); 8 | 9 | #buffer(string) { 10 | const bytes = new Uint8Array(string.length); 11 | [...string].forEach((c, i) => bytes[i] = c.charCodeAt(0)); 12 | return bytes; 13 | } 14 | 15 | async open(password) { 16 | this.#key = await crypto.subtle.digest({ 17 | name: 'SHA-256' 18 | }, this.#encoder.encode(password)).then(result => crypto.subtle.importKey('raw', result, { 19 | name: 'AES-CBC' 20 | }, true, ['encrypt', 'decrypt'])); 21 | } 22 | export() { 23 | return crypto.subtle.exportKey('raw', this.#key).then(ab => { 24 | return btoa(String.fromCharCode(...new Uint8Array(ab))); 25 | }); 26 | } 27 | import(string) {/* Uint8Array */ 28 | const decodedKeyData = new Uint8Array(Array.from(atob(string), c => c.charCodeAt(0))); 29 | 30 | return crypto.subtle.importKey('raw', decodedKeyData, { 31 | name: 'AES-CBC' 32 | }, true, ['encrypt', 'decrypt']).then(key => { 33 | this.#key = key; 34 | }); 35 | } 36 | async encrypt(string) { 37 | const iv = crypto.getRandomValues(new Uint8Array(16)); 38 | 39 | const result = await crypto.subtle.encrypt({ 40 | name: 'AES-CBC', 41 | iv 42 | }, this.#key, this.#encoder.encode(string)); 43 | 44 | return new Promise(resolve => { 45 | const reader = new FileReader(); 46 | reader.onload = () => resolve(reader.result); 47 | reader.readAsDataURL(new Blob([iv, result], {type: 'application/octet-binary'})); 48 | }); 49 | } 50 | async decrypt(string) { 51 | // compatibility fix 52 | string = string.replace('data:application/octet-binary;base64,', ''); 53 | 54 | const iv = crypto.getRandomValues(new Uint8Array(16)); 55 | 56 | const result = await crypto.subtle.decrypt({ 57 | name: 'AES-CBC', 58 | iv 59 | }, this.#key, this.#buffer(atob(string))); 60 | 61 | const ab = (new Uint8Array(result)).subarray(16); 62 | return this.#decoder.decode(ab); 63 | } 64 | } 65 | 66 | { 67 | const s = new Safe(); 68 | self.safe = { 69 | async encrypt(string, password) { 70 | await s.open(password); 71 | return s.encrypt(string); 72 | }, 73 | async decrypt(string, password) { 74 | await s.open(password); 75 | return s.decrypt(string); 76 | } 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /v3/data/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | Save Tabs 11 | 12 |
13 | 22 |
23 |

Sessions

24 |
25 | 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 | -------------------------------------------------------------------------------- /v3/data/editor/index.js: -------------------------------------------------------------------------------- 1 | /* global Sortable */ 2 | 'use strict'; 3 | 4 | let counter; 5 | 6 | const drag = () => { 7 | Sortable.create(document.getElementById('container'), { 8 | animation: 150, 9 | ghostClass: 'blue', 10 | handle: '.handle' 11 | }); 12 | }; 13 | 14 | document.getElementById('cancel').addEventListener('click', () => { 15 | const dialog = window.top.document.getElementById('popup'); 16 | const iframe = dialog.querySelector('iframe'); 17 | iframe.src = ''; 18 | delete iframe.onload; 19 | dialog.close(); 20 | }); 21 | 22 | document.querySelector('form').addEventListener('submit', e => { 23 | e.preventDefault(); 24 | const container = document.getElementById('container'); 25 | const {session, password} = container; 26 | 27 | const tabs = [...document.querySelectorAll('#container .grid')].map((e, index) => ({ 28 | ...e.tab, 29 | title: e.querySelector('input[type=text]:first-of-type').value, 30 | url: e.querySelector('input[type=text]:last-of-type').value, 31 | index 32 | })); 33 | chrome.runtime.sendMessage({ 34 | method: 'update', 35 | session, 36 | password, 37 | tabs 38 | }, () => { 39 | counter.textContent = top.counter(tabs.length); 40 | document.getElementById('cancel').click(); 41 | }); 42 | }); 43 | 44 | self.build = ({tabs, session, password, div}) => { 45 | counter = div.querySelector('[data-id="count"]'); 46 | 47 | const container = document.getElementById('container'); 48 | const t = document.querySelector('template'); 49 | for (const tab of tabs) { 50 | const clone = document.importNode(t.content, true); 51 | const [title, url] = [...clone.querySelectorAll('input[type=text]')]; 52 | title.value = tab.title; 53 | url.value = tab.url; 54 | clone.querySelector('div').tab = tab; 55 | 56 | container.appendChild(clone); 57 | } 58 | drag(); 59 | 60 | Object.assign(container, { 61 | session, 62 | password 63 | }); 64 | }; 65 | 66 | document.getElementById('container').addEventListener('click', e => { 67 | const command = e.target.dataset.command; 68 | if (command === 'remove') { 69 | e.target.closest('.grid').remove(); 70 | } 71 | else if (e.detail === 2 && command === 'open') { 72 | chrome.tabs.create({ 73 | url: e.target.value 74 | }); 75 | } 76 | }); 77 | 78 | document.addEventListener('keydown', e => { 79 | if (e.code === 'Escape') { 80 | e.preventDefault(); 81 | e.stopPropagation(); 82 | document.getElementById('cancel').click(); 83 | } 84 | }); 85 | -------------------------------------------------------------------------------- /v2/firefox/data/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | Save Tabs 11 | 12 |
13 | 21 |
22 |

Sessions

23 |
24 | 25 | 26 |
27 | 28 | 29 |
30 |
31 |
32 |
33 |
34 | 35 | 36 |
37 |
38 |   39 | 40 |
41 |
42 |
43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /v2/firefox/data/dialog/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const args = new URLSearchParams(location.search); 4 | 5 | const prefs = { 6 | sessions: [] 7 | }; 8 | chrome.storage.sync.get({ 9 | sessions: [] 10 | }, ps => Object.assign(prefs.sessions, ps.sessions)); 11 | 12 | document.addEventListener('input', ({target}) => { 13 | if (target.type === 'password') { 14 | const password = document.querySelector('[name="password"]'); 15 | const confirm = document.querySelector('[name="password-confirm"]'); 16 | 17 | confirm.setCustomValidity( 18 | confirm.value === password.value ? '' : 'Does not match with password' 19 | ); 20 | } 21 | if (target.name === 'name') { 22 | const bol = prefs.sessions.indexOf('session.' + target.value) === -1; 23 | target.setCustomValidity(bol ? '' : 'This session name is already taken'); 24 | } 25 | }); 26 | 27 | document.addEventListener('submit', e => { 28 | e.preventDefault(); 29 | chrome.runtime.sendMessage({ 30 | method: 'store', 31 | name: document.querySelector('[name=name]').value, 32 | password: document.querySelector('[name=password]').value, 33 | rule: args.get('method'), 34 | pinned: document.querySelector('[name=pinned]').checked, 35 | internal: document.querySelector('[name=internal]').checked, 36 | permanent: document.querySelector('[name=permanent]').checked 37 | }, bol => { 38 | window.top.document.querySelector('iframe').dataset.visible = false; 39 | if (bol) { 40 | window.top.close(); 41 | top.build(); 42 | } 43 | }); 44 | }); 45 | 46 | document.getElementById('cancel').addEventListener('click', () => { 47 | window.top.document.querySelector('iframe').dataset.visible = false; 48 | }); 49 | // Firefox issue 50 | document.addEventListener('DOMContentLoaded', () => { 51 | const name = document.querySelector('[name=name]'); 52 | name.value = 'session - ' + Math.random().toString(36).substring(7); 53 | name.focus(); 54 | }); 55 | // init 56 | { 57 | const internal = document.querySelector('[name=internal]'); 58 | const permanent = document.querySelector('[name=permanent]'); 59 | const pinned = document.querySelector('[name=pinned]'); 60 | chrome.storage.local.get({ 61 | 'pinned': true, 62 | 'internal': false, 63 | 'permanent': false 64 | }, prefs => { 65 | pinned.checked = prefs.pinned; 66 | permanent.checked = prefs.permanent; 67 | internal.checked = prefs.internal; 68 | if (args.get('silent') === 'true') { 69 | document.dispatchEvent(new Event('submit')); 70 | } 71 | }); 72 | permanent.addEventListener('change', e => chrome.storage.local.set({ 73 | 'permanent': e.target.checked 74 | })); 75 | pinned.addEventListener('change', e => chrome.storage.local.set({ 76 | 'pinned': e.target.checked 77 | })); 78 | internal.addEventListener('change', e => chrome.storage.local.set({ 79 | 'internal': e.target.checked 80 | })); 81 | } 82 | -------------------------------------------------------------------------------- /v3/data/editor/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: light-dark(#fff, #35363a); 3 | --bg-alt: light-dark(#cbccd2, #4e4e4e); 4 | --clr: light-dark(#4d5156, #b1b1b1); 5 | --dark: light-dark(#d9d9d9, #202124); 6 | --button: light-dark(#f0f0f0, #202124); 7 | --link: light-dark(#1c73ff, #86b5ff); 8 | --border: light-dark(#f1f1f1, #84868e); 9 | --opacity: 0.2; 10 | --invert: 0; 11 | 12 | color-scheme: light dark; 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --opacity: 0.5; 18 | --invert: 1; 19 | } 20 | } 21 | 22 | body { 23 | font-size: 13px; 24 | font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; 25 | margin: 0; 26 | color: var(--clr); 27 | background-color: var(--bg); 28 | } 29 | form { 30 | height: 100vh; 31 | display: flex; 32 | flex-direction: column; 33 | } 34 | #container { 35 | flex: 1; 36 | overflow: auto; 37 | } 38 | #tools { 39 | display: grid; 40 | grid-template-columns: min-content min-content; 41 | direction: rtl; 42 | grid-gap: 5px; 43 | padding: 10px; 44 | background-color: var(--bg-alt); 45 | } 46 | #tools input { 47 | border: none; 48 | background: #f1f1f1; 49 | padding: 10px 20px; 50 | color: #000; 51 | cursor: pointer; 52 | } 53 | .grid { 54 | display: grid; 55 | grid-template-columns: 32px 1fr 1fr 32px; 56 | white-space: nowrap; 57 | border: solid 1px var(--border); 58 | margin-bottom: -1px; 59 | } 60 | 61 | @media screen and (max-width: 400px) { 62 | .grid { 63 | grid-template-columns: 32px 1fr 32px; 64 | } 65 | .grid > input { 66 | grid-column-start: 2; 67 | grid-column-end: 3; 68 | } 69 | .grid > span { 70 | grid-row-start: 1; 71 | grid-row-end: 3; 72 | } 73 | .grid > span:first-child { 74 | grid-column-start: 1; 75 | } 76 | .grid > span:last-child { 77 | grid-column-start: 3; 78 | } 79 | } 80 | .grid > span:first-child, 81 | .grid > span:last-child { 82 | background-position: center center; 83 | background-repeat: no-repeat; 84 | opacity: var(--opacity); 85 | } 86 | .grid > span:first-child { 87 | cursor: ns-resize; 88 | background-image: url('drag.svg'); 89 | filter: invert(var(--invert)); 90 | } 91 | .grid > span:last-child { 92 | cursor: pointer; 93 | background-image: url('trash.svg'); 94 | } 95 | .grid:hover > span:first-child, 96 | .grid:hover > span:last-child, 97 | .grid.blue > span:first-child, 98 | .grid.blue > span:last-child { 99 | opacity: 1; 100 | } 101 | .grid.blue { 102 | background-color: #00cdff47; 103 | } 104 | .grid input[type=text] { 105 | border: none; 106 | font-size: inherit; 107 | outline: none; 108 | padding: 10px; 109 | overflow: hidden; 110 | background-color: transparent; 111 | } 112 | @media screen and (max-width: 400px) { 113 | .grid input[type=text] { 114 | padding: 10px 2px; 115 | } 116 | } 117 | .grid input[type=text]:focus { 118 | color: var(--clr) !important; 119 | background-color: var(--bg-alt); 120 | } 121 | .grid input[type=text]:nth-child(3) { 122 | color: var(--link); 123 | cursor: pointer; 124 | } 125 | -------------------------------------------------------------------------------- /v3/data/dialog/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const args = new URLSearchParams(location.search); 4 | 5 | const prefs = { 6 | sessions: [] 7 | }; 8 | chrome.storage.sync.get({ 9 | sessions: [] 10 | }, p1 => { 11 | prefs.sessions.push(p1.sessions); 12 | chrome.storage.local.get({ 13 | sessions: [] 14 | }, p2 => { 15 | prefs.sessions.push(p2.sessions); 16 | }); 17 | }); 18 | 19 | document.addEventListener('input', ({target}) => { 20 | if (target.type === 'password') { 21 | const password = document.querySelector('[name="password"]'); 22 | const confirm = document.querySelector('[name="password-confirm"]'); 23 | 24 | confirm.setCustomValidity( 25 | confirm.value === password.value ? '' : 'Does not match with password' 26 | ); 27 | } 28 | if (target.name === 'name') { 29 | const bol = prefs.sessions.indexOf('session.' + target.value) === -1; 30 | target.setCustomValidity(bol ? '' : 'This session name is already taken'); 31 | } 32 | }); 33 | 34 | document.addEventListener('submit', e => { 35 | e.preventDefault(); 36 | chrome.runtime.sendMessage({ 37 | method: 'store', 38 | name: document.querySelector('[name=name]').value, 39 | password: document.querySelector('[name=password]').value, 40 | rule: args.get('method'), 41 | pinned: document.querySelector('[name=pinned]').checked, 42 | internal: document.querySelector('[name=internal]').checked, 43 | permanent: document.querySelector('[name=permanent]').checked 44 | }, o => { 45 | if ('error' in o) { 46 | alert(o.error); 47 | } 48 | else { 49 | window.top.document.getElementById('popup').close(); 50 | if (o.count > 0) { 51 | window.top.close(); 52 | top.build(); 53 | } 54 | } 55 | }); 56 | }); 57 | 58 | document.getElementById('cancel').addEventListener('click', () => { 59 | window.top.document.getElementById('popup').close(); 60 | }); 61 | // Firefox issue 62 | document.addEventListener('DOMContentLoaded', () => { 63 | const name = document.querySelector('[name=name]'); 64 | name.value = 'session - ' + Math.random().toString(36).substring(7); 65 | name.focus(); 66 | }); 67 | // init 68 | { 69 | const internal = document.querySelector('[name=internal]'); 70 | const permanent = document.querySelector('[name=permanent]'); 71 | const pinned = document.querySelector('[name=pinned]'); 72 | chrome.storage.local.get({ 73 | 'pinned': true, 74 | 'internal': false, 75 | 'permanent': true 76 | }, prefs => { 77 | pinned.checked = prefs.pinned; 78 | permanent.checked = prefs.permanent; 79 | internal.checked = prefs.internal; 80 | if (args.get('silent') === 'true') { 81 | document.dispatchEvent(new Event('submit')); 82 | } 83 | }); 84 | permanent.addEventListener('change', e => chrome.storage.local.set({ 85 | 'permanent': e.target.checked 86 | })); 87 | pinned.addEventListener('change', e => chrome.storage.local.set({ 88 | 'pinned': e.target.checked 89 | })); 90 | internal.addEventListener('change', e => chrome.storage.local.set({ 91 | 'internal': e.target.checked 92 | })); 93 | } 94 | 95 | document.addEventListener('keydown', e => { 96 | if (e.code === 'Escape') { 97 | e.preventDefault(); 98 | e.stopPropagation(); 99 | document.getElementById('cancel').click(); 100 | } 101 | }); 102 | -------------------------------------------------------------------------------- /v2/firefox/data/dialog/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Save Tabs 6 | 7 | 8 | 9 | 10 | 91 |
92 | Session name 93 | 94 | Password 95 | 96 | Repeat Password 97 | 98 |
99 | Internal Tabs 100 | 101 |
102 | Pinned Tabs 103 | 104 | Permanent 105 | 106 | 107 |
108 | 109 | 110 |
111 |
112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /v2/firefox/data/popup/index.css: -------------------------------------------------------------------------------- 1 | @media screen and (max-width: 400px) { 2 | .sh { 3 | display: none; 4 | } 5 | } 6 | 7 | body { 8 | font-family: "Helvetica Neue", Helvetica, sans-serif; 9 | font-size: 13px; 10 | background-color: #fff; 11 | color: #4d5156; 12 | margin: 0; 13 | width: 550px; 14 | padding-bottom: 10px; 15 | user-select: none; 16 | max-height: 500px; 17 | min-height: 240px; 18 | } 19 | body.vertical { 20 | width: unset; 21 | max-height: unset; 22 | min-height: unset; 23 | overflow: hidden; 24 | height: 100vh; 25 | } 26 | @media (pointer: none), (pointer: coarse) { 27 | body { 28 | width: unset; 29 | max-height: unset; 30 | min-height: unset; 31 | overflow: hidden; 32 | } 33 | } 34 | header { 35 | text-indent: 64px; 36 | background: url('../icons/48.png') left 10px center no-repeat; 37 | min-height: 64px; 38 | border-bottom: solid 1px #d4d4d4; 39 | margin-bottom: 10px; 40 | white-space: nowrap; 41 | display: grid; 42 | grid-template-columns: min-content 1fr; 43 | align-items: center; 44 | } 45 | header > span { 46 | font-size: 140%; 47 | } 48 | header > label { 49 | justify-self: end; 50 | margin-right: 10px; 51 | } 52 | h1 { 53 | font-weight: normal; 54 | font-size: 140%; 55 | display: inline-block; 56 | margin: 0; 57 | } 58 | ul { 59 | list-style: none; 60 | margin: 0; 61 | padding: 0; 62 | } 63 | li { 64 | cursor: pointer; 65 | padding: 3px 10px; 66 | white-space: nowrap; 67 | overflow: hidden; 68 | text-overflow: ellipsis; 69 | } 70 | li:hover { 71 | background-color: #eee; 72 | } 73 | table { 74 | width: calc(100% - 10px); 75 | table-layout: fixed; 76 | } 77 | tbody:empty::before { 78 | content: 'no session to restore yet!'; 79 | font-style: italic; 80 | color: #ccc; 81 | padding-left: 10px; 82 | } 83 | iframe { 84 | position: absolute; 85 | left: 10px; 86 | width: calc(100% - 20px); 87 | top: 10px; 88 | height: calc(100% - 20px); 89 | border: none; 90 | box-shadow: 0 0 0 100px rgba(0, 0, 0, 0.6); 91 | background-color: #fff; 92 | } 93 | iframe[data-visible=false] { 94 | display: none; 95 | } 96 | input[type=submit], 97 | input[type=button] { 98 | border: none; 99 | padding: 5px 20px; 100 | background: #f1f1f1; 101 | cursor: pointer; 102 | } 103 | 104 | #sessions { 105 | overflow: auto; 106 | display: grid; 107 | grid-template-columns: 1fr 32px min-content 32px min-content 32px 32px; 108 | white-space: nowrap; 109 | grid-column-gap: 3px; 110 | grid-row-gap: 2px; 111 | margin: 5px 10px 5px 8px; 112 | align-items: center; 113 | width: calc(100vw - 18px); 114 | } 115 | #sessions > div { 116 | display: contents; 117 | } 118 | 119 | #sessions td:nth-child(1) { 120 | overflow: hidden; 121 | text-overflow: ellipsis; 122 | white-space: nowrap; 123 | cursor: pointer; 124 | padding: 3px 10px; 125 | } 126 | #sessions [data-session] { 127 | overflow: hidden; 128 | text-overflow: ellipsis; 129 | padding: 2px 5px; 130 | cursor: pointer; 131 | } 132 | #sessions [data-session]:hover { 133 | background-color: #eee; 134 | } 135 | #sessions > div > span:nth-child(2) { 136 | height: 100%; 137 | background-position: center center; 138 | background-repeat: no-repeat; 139 | background-image: url('unlock.svg'); 140 | background-size: 16px; 141 | } 142 | #sessions > div[data-locked=true] > span:nth-child(2) { 143 | background-image: url('locked.svg'); 144 | } 145 | #sessions [data-cmd=overwrite], 146 | #sessions [data-cmd=preview], 147 | #sessions [data-cmd=remove] { 148 | cursor: pointer; 149 | background-color: #f1f1f1; 150 | background-size: 14px 14px; 151 | background-repeat: no-repeat; 152 | background-position: center center; 153 | height: 18px; 154 | width: 32px; 155 | } 156 | #sessions [data-cmd=overwrite] { 157 | background-image: url(overwrite.svg); 158 | } 159 | #sessions [data-cmd].disabled, 160 | #sessions > div[data-locked=true] [data-cmd=overwrite] { 161 | opacity: 0.2; 162 | pointer-events: none; 163 | } 164 | #sessions [data-cmd=preview] { 165 | background-image: url(preview.svg); 166 | } 167 | #sessions [data-cmd=remove] { 168 | background-image: url(remove.svg); 169 | } 170 | #sessions [data-cmd=overwrite]:hover, 171 | #sessions [data-cmd=preview]:hover, 172 | #sessions [data-cmd=remove]:hover { 173 | background-color: #e45e5e; 174 | color: #fff; 175 | } 176 | #sessions > div[data-permanent=true] [data-session] { 177 | color: #1c73ff; 178 | } 179 | @media screen and (max-width: 400px) { 180 | #sessions > div > span:nth-child(2), 181 | #sessions > div > span:nth-child(3), 182 | #sessions > div > span:nth-child(5) { 183 | display: none; 184 | } 185 | #sessions { 186 | grid-template-columns: 1fr 32px 32px 32px; 187 | grid-column-gap: 5px; 188 | } 189 | } 190 | #prompt { 191 | position: absolute; 192 | left: 0; 193 | top: 0; 194 | width: 100%; 195 | height: 100%; 196 | background-color: rgba(0, 0, 0, 0.2); 197 | display: flex; 198 | align-items: center; 199 | justify-content: center; 200 | } 201 | #prompt[data-visible=false] { 202 | display: none; 203 | } 204 | #prompt > form { 205 | width: calc(100% - 60px); 206 | height: 100px; 207 | background-color: #fff; 208 | border: solid 1px #d4d4d4; 209 | padding: 10px; 210 | } 211 | #prompt input[type=password] { 212 | width: 100%; 213 | box-sizing: border-box; 214 | outline: none; 215 | text-indent: 5px; 216 | padding: 5px; 217 | margin-top: 5px; 218 | } 219 | #prompt[data-type=confirm] input[type=password] { 220 | display: none; 221 | } 222 | #manager { 223 | display: grid; 224 | grid-template-columns: 1fr repeat(3, min-content); 225 | white-space: nowrap; 226 | align-items: center; 227 | margin: 10px; 228 | grid-gap: 5px; 229 | } 230 | #manager > :not(:first-child) { 231 | justify-self: end; 232 | } 233 | @media screen and (max-width: 400px) { 234 | #manager { 235 | grid-template-columns: 1fr min-content min-content; 236 | } 237 | #manager > :first-child { 238 | grid-column-start: 1; 239 | grid-column-end: 4; 240 | } 241 | } 242 | 243 | body[data-count="0"] #manager, 244 | body[data-count="0"] #sessions { 245 | display: none; 246 | } 247 | 248 | .all, 249 | .current, 250 | .other { 251 | padding: 0 2px; 252 | pointer-events: none; 253 | } 254 | .all { 255 | background-color: #ffefbe; 256 | } 257 | .other { 258 | background-color: #c6ffc6; 259 | } 260 | .current { 261 | background-color: #e5e5ff; 262 | } 263 | 264 | [hbox] { 265 | display: flex; 266 | } 267 | [vbox] { 268 | display: flex; 269 | flex-direction: column; 270 | } 271 | [flex="1"] { 272 | flex: 1; 273 | } 274 | [pack=center] { 275 | justify-content: center; 276 | } 277 | [align=center] { 278 | align-items: center; 279 | } 280 | [pack=start] { 281 | justify-content: flex-start; 282 | } 283 | [align=start] { 284 | align-items: flex-start; 285 | } 286 | [pack=end] { 287 | justify-content: flex-end; 288 | } 289 | [align=end] { 290 | align-items: flex-end; 291 | } 292 | -------------------------------------------------------------------------------- /v2/firefox/data/popup/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (location.href.indexOf('mode=vertical') !== -1) { 4 | document.body.classList.add('vertical'); 5 | } 6 | 7 | const prompt = document.getElementById('prompt'); 8 | const ask = (msg, type = 'prompt') => new Promise((resolve, reject) => { 9 | const password = prompt.querySelector('[type=password]'); 10 | const callback = e => { 11 | e.preventDefault(); 12 | prompt.reject = ''; 13 | prompt.removeEventListener('submit', callback); 14 | prompt.dataset.visible = false; 15 | resolve(password.value); 16 | }; 17 | prompt.querySelector('span').textContent = msg; 18 | prompt.dataset.visible = true; 19 | prompt.dataset.type = type; 20 | password[type === 'prompt' ? 'setAttribute' : 'removeAttribute']('required', true); 21 | prompt.addEventListener('submit', callback); 22 | prompt.reject = reject; 23 | if (type === 'prompt') { 24 | password.focus(); 25 | password.value = ''; 26 | } 27 | }); 28 | prompt.addEventListener('click', ({target}) => { 29 | if (target === prompt) { 30 | prompt.dataset.visible = false; 31 | if (prompt.reject) { 32 | prompt.reject(); 33 | } 34 | } 35 | }); 36 | prompt.querySelector('input[type=button]').addEventListener('click', () => { 37 | prompt.dispatchEvent(new Event('click')); 38 | }); 39 | 40 | document.addEventListener('click', async e => { 41 | const target = e.target; 42 | const method = target.dataset.cmd; 43 | if (method === 'remove') { 44 | await ask('The session data will be erased. Are you sure?', 'confirm'); 45 | chrome.storage.sync.get({ 46 | sessions: [] 47 | }, prefs => { 48 | const div = target.closest('div[data-session]'); 49 | const {session} = div.dataset; 50 | const index = prefs.sessions.indexOf(session); 51 | prefs.sessions.splice(index, 1); 52 | document.body.dataset.count = prefs.sessions.length; 53 | chrome.storage.sync.set(prefs, () => chrome.storage.sync.remove(session, () => { 54 | div.remove(); 55 | })); 56 | }); 57 | } 58 | else if (method === 'preview') { 59 | const div = target.closest('div[data-session]'); 60 | const {locked, session} = div.dataset; 61 | const password = locked === 'true' ? await ask('Enter the Session Password') : ''; 62 | 63 | const iframe = document.querySelector('iframe'); 64 | iframe.onload = () => chrome.runtime.sendMessage({ 65 | method, 66 | session, 67 | password 68 | }, tabs => { 69 | if (Array.isArray(tabs)) { 70 | iframe.contentWindow.build({tabs, password, session}); 71 | } 72 | else { 73 | iframe.dataset.visible = false; 74 | delete iframe.onload; 75 | } 76 | }); 77 | iframe.dataset.visible = true; 78 | iframe.src = '/data/editor/index.html'; 79 | } 80 | else if (method === 'restore') { 81 | const div = target.closest('div[data-session]'); 82 | const {locked, session, permanent} = div.dataset; 83 | chrome.runtime.sendMessage({ 84 | method, 85 | session, 86 | password: locked === 'true' ? await ask('Enter the Session Password') : '', 87 | remove: e.shiftKey === false, 88 | single: document.getElementById('single').checked, 89 | discard: document.getElementById('discard').checked, 90 | clean: document.getElementById('clean').checked 91 | }, () => { 92 | if (permanent !== 'true') { 93 | window.close(); 94 | div.remove(); 95 | } 96 | }); 97 | } 98 | else if (method && method.startsWith('save-')) { 99 | const iframe = document.querySelector('iframe'); 100 | iframe.dataset.visible = true; 101 | iframe.src = '/data/dialog/index.html?method=' + method + '&silent=' + document.getElementById('silent').checked; 102 | } 103 | else if (method === 'overwrite') { 104 | await ask('Your session will be overwritten by open tabs. Are you sure?', 'confirm'); 105 | e.target.classList.add('disabled'); 106 | const div = target.closest('div[data-session]'); 107 | const {session} = div.dataset; 108 | chrome.storage.local.get({ 109 | 'pinned': true, 110 | 'internal': false 111 | }, prefs => chrome.runtime.sendMessage({ 112 | method: 'overwrite', 113 | session, 114 | rule: 'save-tabs', 115 | ...prefs 116 | }, length => { 117 | e.target.classList.remove('disabled'); 118 | if (length) { 119 | e.target.closest('[data-session]').querySelector('[data-id=count]').textContent = length + ' Tabs'; 120 | } 121 | })); 122 | } 123 | else if (method) { 124 | chrome.runtime.sendMessage({ 125 | method 126 | }); 127 | } 128 | }); 129 | 130 | const format = num => { 131 | const d = new Date(num); 132 | return `${d.getFullYear().toString().substr(-2)}.${('00' + (d.getMonth() + 1)).substr(-2)}.${('00' + d.getDate()).substr(-2)} ` + 133 | `${('00' + d.getHours()).substr(-2)}:${('00' + d.getMinutes()).substr(-2)}`; 134 | }; 135 | 136 | const build = () => chrome.storage.sync.get(null, prefs => { 137 | const sessions = document.getElementById('sessions'); 138 | sessions.textContent = ''; 139 | const f = document.createDocumentFragment(); 140 | 141 | prefs.sessions = prefs.sessions || []; 142 | document.body.dataset.count = prefs.sessions.length; 143 | prefs.sessions.forEach(session => { 144 | const obj = prefs[session] || {}; 145 | // fix password protected 146 | obj.protected = obj.json.startsWith('data:application/octet-binary;'); 147 | 148 | const div = document.createElement('div'); 149 | div.dataset.session = session; 150 | div.dataset.locked = obj.protected; 151 | const name = document.createElement('span'); 152 | name.textContent = session.replace(/^session\./, ''); 153 | name.dataset.session = session; 154 | name.dataset.cmd = 'restore'; 155 | name.title = name.textContent + ` 156 | 157 | Shift + click: restore without removing the session`; 158 | div.appendChild(name); 159 | div.appendChild(document.createElement('span')); 160 | const number = document.createElement('span'); 161 | number.dataset.id = 'count'; 162 | number.textContent = obj.tabs + ' Tabs'; 163 | div.appendChild(number); 164 | const preview = document.createElement('span'); 165 | preview.dataset.cmd = 'preview'; 166 | preview.title = 'Preview this Session'; 167 | div.appendChild(preview); 168 | const date = document.createElement('span'); 169 | date.textContent = format(obj.timestamp); 170 | div.appendChild(date); 171 | const overwrite = document.createElement('span'); 172 | overwrite.dataset.cmd = 'overwrite'; 173 | overwrite.title = 'Overwrite this Session with Open Tabs'; 174 | div.appendChild(overwrite); 175 | const close = document.createElement('span'); 176 | close.dataset.cmd = 'remove'; 177 | close.title = 'Remove this Session'; 178 | div.appendChild(close); 179 | div.dataset.permanent = obj.permanent; 180 | f.appendChild(div); 181 | }); 182 | sessions.appendChild(f); 183 | }); 184 | window.build = build; 185 | document.addEventListener('DOMContentLoaded', build); 186 | 187 | // persist 188 | document.addEventListener('DOMContentLoaded', () => chrome.storage.local.get({ 189 | 'silent': false, 190 | 'single': false, 191 | 'discard': false, 192 | 'clean': false 193 | }, prefs => { 194 | document.getElementById('silent').checked = prefs.silent; 195 | document.getElementById('single').checked = prefs.single; 196 | document.getElementById('normal').checked = prefs.single === false; 197 | document.getElementById('discard').checked = prefs.discard; 198 | document.getElementById('clean').checked = prefs.clean; 199 | })); 200 | document.getElementById('silent').addEventListener('change', e => chrome.storage.local.set({ 201 | 'silent': e.target.checked 202 | })); 203 | document.getElementById('manager').addEventListener('change', () => chrome.storage.local.set({ 204 | 'single': document.getElementById('single').checked, 205 | 'discard': document.getElementById('discard').checked, 206 | 'clean': document.getElementById('clean').checked 207 | })); 208 | -------------------------------------------------------------------------------- /v3/data/popup/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: light-dark(#fff, #35363a); 3 | --clr: light-dark(#4d5156, #b1b1b1); 4 | --dark: light-dark(#d9d9d9, #202124); 5 | --button: light-dark(#f1f1f1, #b7b7b7); 6 | --button-fg: light-dark(#000, #000); 7 | --bg-hover: light-dark(#f3f3f3, #4a4a4a); 8 | --bg-hover-alt: light-dark(#f3f3f3, #a2a2a2); 9 | --clr-hover: light-dark(#000, #eee); 10 | --link: light-dark(#1c73ff, #86b5ff); 11 | --invert: 0; 12 | --brightness: 1; 13 | 14 | color-scheme: light dark; 15 | } 16 | 17 | @media (prefers-color-scheme: dark) { 18 | :root { 19 | --invert: 1; 20 | --brightness: 2; 21 | } 22 | } 23 | 24 | @media screen and (max-width: 400px) { 25 | .sh { 26 | display: none; 27 | } 28 | } 29 | 30 | body { 31 | font-family: "Helvetica Neue", Helvetica, sans-serif; 32 | font-size: 13px; 33 | background-color: var(--bg); 34 | color: var(--clr); 35 | margin: 0; 36 | width: 600px; 37 | padding-bottom: 10px; 38 | user-select: none; 39 | max-height: 500px; 40 | min-height: 240px; 41 | } 42 | body:has(dialog[open]) { 43 | min-height: 300px; 44 | } 45 | body.vertical { 46 | width: unset; 47 | max-height: unset; 48 | min-height: unset; 49 | overflow: hidden; 50 | height: 100vh; 51 | } 52 | @media (pointer: none), (pointer: coarse) { 53 | body { 54 | width: unset; 55 | max-height: unset; 56 | min-height: unset; 57 | overflow: hidden; 58 | } 59 | } 60 | header { 61 | text-indent: 64px; 62 | background: url('../icons/128.png') left 10px center no-repeat; 63 | background-size: 48px; 64 | min-height: 64px; 65 | border-bottom: solid 3px #d4d4d4; 66 | margin-bottom: 10px; 67 | white-space: nowrap; 68 | display: grid; 69 | grid-template-columns: 1fr min-content ; 70 | align-items: center; 71 | } 72 | header > span { 73 | font-size: 140%; 74 | } 75 | header > label { 76 | margin-right: 10px; 77 | white-space: nowrap; 78 | display: flex; 79 | gap: 5px; 80 | text-indent: initial; 81 | } 82 | h1 { 83 | font-weight: normal; 84 | font-size: 140%; 85 | display: inline-block; 86 | margin: 0; 87 | } 88 | ul { 89 | list-style: none; 90 | margin: 0; 91 | padding: 0; 92 | } 93 | li { 94 | cursor: pointer; 95 | padding: 3px 10px; 96 | white-space: nowrap; 97 | overflow: hidden; 98 | text-overflow: ellipsis; 99 | } 100 | li:hover { 101 | background-color: var(--bg-hover); 102 | color: var(--clr-hover); 103 | } 104 | table { 105 | width: calc(100% - 10px); 106 | table-layout: fixed; 107 | } 108 | tbody:empty::before { 109 | content: 'no session to restore yet!'; 110 | font-style: italic; 111 | color: #ccc; 112 | padding-left: 10px; 113 | } 114 | #popup { 115 | padding: 0; 116 | border: none; 117 | } 118 | #popup[open] { 119 | display: flex; 120 | } 121 | #popup iframe { 122 | width: calc(100vw - 60px); 123 | height: calc(100vh - 60px); 124 | border: none; 125 | background-color: var(--bg); 126 | } 127 | input[type=submit], 128 | input[type=button] { 129 | border: none; 130 | padding: 5px 20px; 131 | color: var(--button-fg); 132 | background: var(--button); 133 | cursor: pointer; 134 | } 135 | input[type=checkbox], 136 | input[type=radio] { 137 | margin: 0 5px; 138 | } 139 | 140 | #sessions { 141 | overflow: auto; 142 | display: grid; 143 | grid-template-columns: 1fr min-content min-content min-content min-content min-content min-content min-content min-content min-content; 144 | white-space: nowrap; 145 | grid-gap: 5px 2px; 146 | margin: 5px 10px 5px 8px; 147 | align-items: center; 148 | width: calc(100vw - 18px); 149 | } 150 | #sessions > div { 151 | display: contents; 152 | } 153 | #sessions > div.header > * { 154 | padding: 3px; 155 | position: sticky; 156 | top: 0; 157 | background-color: var(--bg-hover); 158 | text-align: center; 159 | font-weight: 500; 160 | z-index: 2; 161 | } 162 | 163 | #sessions td:nth-child(1) { 164 | overflow: hidden; 165 | text-overflow: ellipsis; 166 | white-space: nowrap; 167 | cursor: pointer; 168 | padding: 3px 10px; 169 | } 170 | #sessions [data-session] { 171 | overflow: hidden; 172 | text-overflow: ellipsis; 173 | cursor: pointer; 174 | } 175 | #sessions [data-session]:hover { 176 | background-color: var(--bg-hover); 177 | } 178 | #sessions [data-id="lock"] { 179 | height: 100%; 180 | background-position: center center; 181 | background-repeat: no-repeat; 182 | background-image: url('unlock.svg'); 183 | filter: brightness(var(--brightness)); 184 | background-size: 16px; 185 | } 186 | #sessions > div[data-locked="true"] > [data-id="lock"] { 187 | background-image: url('locked.svg'); 188 | filter: none; 189 | } 190 | #sessions [data-id="permanent"], 191 | #sessions [data-id="synced"] { 192 | text-align: center; 193 | } 194 | #sessions [data-cmd=overwrite], 195 | #sessions [data-cmd=preview], 196 | #sessions [data-cmd=rename], 197 | #sessions [data-cmd=remove] { 198 | cursor: pointer; 199 | background-color: var(--button); 200 | filter: invert(var(--invert)); 201 | background-size: 14px 14px; 202 | background-repeat: no-repeat; 203 | background-position: center center; 204 | height: 18px; 205 | width: 32px; 206 | } 207 | #sessions [data-cmd=overwrite] { 208 | background-image: url("overwrite.svg"); 209 | } 210 | #sessions [data-cmd].disabled, 211 | #sessions > div[data-locked=true] [data-cmd=overwrite] { 212 | opacity: 0.2; 213 | pointer-events: none; 214 | } 215 | #sessions [data-cmd=preview] { 216 | background-image: url("preview.svg"); 217 | } 218 | #sessions [data-cmd=rename] { 219 | background-image: url("rename.svg"); 220 | } 221 | #sessions [data-cmd=remove] { 222 | background-image: url("remove.svg"); 223 | } 224 | #sessions [data-cmd=overwrite]:hover, 225 | #sessions [data-cmd=rename]:hover, 226 | #sessions [data-cmd=preview]:hover, 227 | #sessions [data-cmd=remove]:hover { 228 | background-color: var(--bg-hover-alt); 229 | color: var(--clr-hover); 230 | } 231 | #sessions > div [data-session] { 232 | color: var(--link); 233 | padding: 3px; 234 | } 235 | #prompt { 236 | padding: 0; 237 | border: none; 238 | } 239 | #prompt[open] { 240 | display: flex; 241 | } 242 | #prompt > form { 243 | width: calc(100vw - 60px); 244 | height: 100px; 245 | background-color: var(--bg); 246 | border: solid 1px #d4d4d4; 247 | padding: 10px; 248 | } 249 | #prompt input[type="text"], 250 | #prompt input[type="password"] { 251 | width: 100%; 252 | box-sizing: border-box; 253 | outline: none; 254 | text-indent: 5px; 255 | padding: 5px; 256 | margin-top: 5px; 257 | } 258 | #prompt:not([data-type="password"]) input[type="password"] { 259 | display: none; 260 | } 261 | #prompt:not([data-type="prompt"]) input[type="text"] { 262 | display: none; 263 | } 264 | #manager { 265 | display: grid; 266 | grid-template-columns: 1fr repeat(3, min-content); 267 | white-space: nowrap; 268 | align-items: center; 269 | margin: 10px; 270 | grid-gap: 5px; 271 | } 272 | #manager > :not(:first-child) { 273 | justify-self: end; 274 | } 275 | 276 | #manager div, 277 | #manager label { 278 | display: flex; 279 | align-items: center; 280 | } 281 | 282 | @media screen and (max-width: 400px) { 283 | #manager { 284 | grid-template-columns: 1fr min-content min-content; 285 | } 286 | #manager > :first-child { 287 | grid-column-start: 1; 288 | grid-column-end: 4; 289 | } 290 | } 291 | 292 | body[data-count="0"] #manager, 293 | body[data-count="0"] #sessions { 294 | display: none; 295 | } 296 | 297 | .all, 298 | .current, 299 | .selected, 300 | .other { 301 | padding: 0 2px; 302 | pointer-events: none; 303 | } 304 | .all { 305 | color: #000; 306 | background-color: #ffefbe; 307 | } 308 | .other { 309 | color: #000; 310 | background-color: #c6ffc6; 311 | } 312 | .current { 313 | color: #000; 314 | background-color: #e5e5ff; 315 | } 316 | .selected { 317 | color: #000; 318 | background-color: #ffd6ed; 319 | } 320 | 321 | [hbox] { 322 | display: flex; 323 | } 324 | [vbox] { 325 | display: flex; 326 | flex-direction: column; 327 | } 328 | [flex="1"] { 329 | flex: 1; 330 | } 331 | [pack=center] { 332 | justify-content: center; 333 | } 334 | [align=center] { 335 | align-items: center; 336 | } 337 | [pack=start] { 338 | justify-content: flex-start; 339 | } 340 | [align=start] { 341 | align-items: flex-start; 342 | } 343 | [pack=end] { 344 | justify-content: flex-end; 345 | } 346 | [align=end] { 347 | align-items: flex-end; 348 | } 349 | -------------------------------------------------------------------------------- /v2/firefox/background.js: -------------------------------------------------------------------------------- 1 | /* globals safe */ 2 | 'use strict'; 3 | 4 | const storage = { 5 | get: prefs => new Promise(resolve => chrome.storage.sync.get(prefs, resolve)), 6 | set: prefs => new Promise(resolve => chrome.storage.sync.set(prefs, resolve)), 7 | remove: arr => new Promise(resolve => chrome.storage.sync.remove(arr, resolve)) 8 | }; 9 | 10 | const notify = message => chrome.notifications.create({ 11 | type: 'basic', 12 | title: chrome.runtime.getManifest().name, 13 | message, 14 | iconUrl: 'data/icons/48.png' 15 | }); 16 | 17 | chrome.runtime.onMessage.addListener((request, sender, response) => { 18 | if (request.method === 'store' || request.method === 'overwrite') { 19 | recording.perform(request, response, request.method === 'store' ? 'new' : 'update'); 20 | return true; 21 | } 22 | else if (request.method === 'update') { 23 | recording.disk(request.tabs, request, 'update'); 24 | response(true); 25 | } 26 | else if (request.method === 'restore' || request.method === 'preview') { 27 | storage.get({ 28 | [request.session]: {}, 29 | 'sessions': [] 30 | }).then(async prefs => { 31 | const session = prefs[request.session]; 32 | session.protected = session.json.startsWith('data:application/octet-binary;'); 33 | try { 34 | const tabs = JSON.parse( 35 | session.protected ? await safe.decrypt(session.json, request.password) : session.json 36 | ); 37 | if (request.method === 'preview') { 38 | return response(tabs); 39 | } 40 | // remove currents 41 | const removeTabs = []; 42 | if (request.clean) { 43 | await new Promise(resolve => chrome.tabs.query({}, tabs => { 44 | removeTabs.push(...tabs); 45 | resolve(); 46 | })); 47 | } 48 | // restore 49 | const create = (tab, props) => new Promise(resolve => { 50 | const discarded = request.discard && tab.active !== true; 51 | if (/Firefox/.test(navigator.userAgent)) { 52 | props = {...props, discarded, url: tab.url}; 53 | if (discarded) { 54 | props.title = tab.title; 55 | } 56 | chrome.tabs.create(props, resolve); 57 | } 58 | else { 59 | let url = tab.url; 60 | if (discarded && url.startsWith('http')) { 61 | url = chrome.runtime.getURL('data/discard/index.html?href=' + 62 | encodeURIComponent(tab.url)) + '&title=' + encodeURIComponent(tab.title); 63 | } 64 | chrome.tabs.create({ 65 | ...props, 66 | url 67 | }, resolve); 68 | } 69 | }); 70 | const groups = {}; 71 | if (request.single) { 72 | for (const t of tabs) { 73 | const props = { 74 | pinned: t.pinned, 75 | active: t.active 76 | }; 77 | if ('cookieStoreId' in t) { 78 | props.cookieStoreId = t.cookieStoreId; 79 | } 80 | const tab = await create(t, props); 81 | if ('groupId' in t) { 82 | groups[t.groupId] = groups[t.groupId] || []; 83 | groups[t.groupId].push(tab.id); 84 | } 85 | } 86 | } 87 | else { 88 | const windows = {}; 89 | tabs.forEach(t => { 90 | windows[t.windowId] = windows[t.windowId] || []; 91 | windows[t.windowId].push(t); 92 | }); 93 | // sort 94 | Object.keys(windows).forEach(id => windows[id].sort((a, b) => a.index - b.index)); 95 | // restore 96 | for (const id of Object.keys(windows)) { 97 | const tab = windows[id][0]; 98 | const props = { 99 | incognito: tab.incognito 100 | }; 101 | if ('window' in tab && tab.window.width) { 102 | props.left = tab.window.left; 103 | props.top = tab.window.top; 104 | props.width = tab.window.width; 105 | props.height = tab.window.height; 106 | } 107 | const win = await new Promise(resolve => chrome.windows.create(props, resolve)); 108 | const toberemoved = win.tabs; 109 | for (const t of windows[id]) { 110 | const props = { 111 | pinned: t.pinned, 112 | active: t.active, 113 | windowId: win.id, 114 | index: t.index 115 | }; 116 | if ('cookieStoreId' in t) { 117 | props.cookieStoreId = t.cookieStoreId; 118 | } 119 | const tab = await create(t, props); 120 | if ('groupId' in t) { 121 | groups[t.groupId] = groups[t.groupId] || []; 122 | groups[t.groupId].windowId = win.id; 123 | groups[t.groupId].push(tab.id); 124 | } 125 | } 126 | for (const {id} of toberemoved) { 127 | chrome.tabs.remove(id); 128 | } 129 | } 130 | } 131 | if ('group' in chrome.tabs) { 132 | for (let groupId of Object.keys(groups)) { 133 | groupId = Number(groupId); 134 | if (isNaN(groupId) === false && groupId > -1) { 135 | chrome.tabs.group({ 136 | createProperties: { 137 | windowId: groups[groupId].windowId 138 | }, 139 | tabIds: groups[groupId] 140 | }); 141 | } 142 | } 143 | } 144 | if (request.remove && session.permanent !== true) { 145 | const index = prefs.sessions.indexOf(request.session); 146 | prefs.sessions.splice(index, 1); 147 | await storage.set({ 148 | sessions: prefs.sessions 149 | }); 150 | await storage.remove(request.session); 151 | } 152 | if (removeTabs.length) { 153 | chrome.tabs.remove(removeTabs.map(t => t.id)); 154 | } 155 | } 156 | catch (e) { 157 | console.error(e); 158 | notify('Cannot restore tabs. Wrong password?'); 159 | response(false); 160 | } 161 | }); 162 | 163 | return request.method === 'restore' ? false : true; 164 | } 165 | }); 166 | 167 | // recording 168 | const recording = { 169 | async disk(tabs, request, type = 'new') { 170 | const windowIds = new Set(tabs.map(t => t.windowId)); 171 | const map = new Map(); 172 | for (const windowId of windowIds) { 173 | const win = await new Promise(resolve => chrome.windows.get(windowId, resolve)); 174 | map.set(windowId, win); 175 | } 176 | 177 | let json = JSON.stringify(tabs.map(t => { 178 | let url = t.url; 179 | if (url.startsWith('chrome-extension://') && url.indexOf(chrome.runtime.id) !== -1) { 180 | url = (new URLSearchParams(url.split('?')[1])).get('href'); 181 | } 182 | const win = map.get(t.windowId); 183 | return { 184 | url, 185 | title: t.title, 186 | active: t.active, 187 | pinned: t.pinned, 188 | incognito: t.incognito, 189 | index: t.index, 190 | windowId: t.windowId, 191 | window: { 192 | focused: win.focused, 193 | type: win.type, 194 | left: win.left, 195 | top: win.top, 196 | width: win.width, 197 | height: win.height 198 | }, 199 | cookieStoreId: t.cookieStoreId, 200 | groupId: t.groupId 201 | }; 202 | })); 203 | if (request.password) { 204 | json = await safe.encrypt(json, request.password); 205 | } 206 | const name = type === 'new' ? 'session.' + request.name : request.session; 207 | const prefs = await storage.get({ 208 | sessions: [], 209 | [name]: {} 210 | }); 211 | if (type === 'new') { 212 | prefs.sessions.push(name); 213 | } 214 | Object.assign(prefs[name], { 215 | json, 216 | timestamp: Date.now(), 217 | tabs: tabs.length 218 | }); 219 | if (type === 'new') { 220 | Object.assign(prefs[name], { 221 | permanent: request.permanent, 222 | protected: Boolean(request.password), 223 | query: { 224 | rule: request.rule, 225 | pinned: request.pinned, 226 | internal: request.internal 227 | } 228 | }); 229 | } 230 | await storage.set(prefs); 231 | }, 232 | perform(request, response, type = 'new') { 233 | const props = { 234 | windowType: 'normal' 235 | }; 236 | if (request.rule.startsWith('save-window')) { 237 | props.currentWindow = true; 238 | } 239 | if (request.rule.startsWith('save-other-windows')) { 240 | props.currentWindow = false; 241 | } 242 | if (request.pinned === false) { 243 | props.pinned = false; 244 | } 245 | chrome.tabs.query(props, async tabs => { 246 | if (request.internal !== true) { 247 | tabs = tabs.filter( 248 | ({url}) => url && 249 | url.startsWith('file://') === false && 250 | url.startsWith('chrome://') === false && 251 | ( 252 | url.startsWith('chrome-extension://') === false || 253 | (url.startsWith('chrome-extension://') && url.indexOf(chrome.runtime.id) !== -1) 254 | ) && 255 | url.startsWith('moz-extension://') === false && 256 | url.startsWith('about:') === false 257 | ); 258 | } 259 | if (tabs.length === 0) { 260 | notify('nothing to save'); 261 | return response(false); 262 | } 263 | await recording.disk(tabs, request, type); 264 | if (request.rule === 'save-tabs-close') { 265 | chrome.tabs.create({ 266 | url: 'about:blank' 267 | }, () => chrome.tabs.remove(tabs.map(t => t.id))); 268 | } 269 | else if (request.rule.endsWith('-close')) { 270 | chrome.tabs.remove(tabs.map(t => t.id)); 271 | } 272 | response(tabs.length); 273 | }); 274 | } 275 | }; 276 | 277 | // context menu 278 | { 279 | const onstartup = () => { 280 | chrome.contextMenus.create({ 281 | title: 'Append JSON sessions', 282 | id: 'append', 283 | contexts: ['browser_action'] 284 | }); 285 | chrome.contextMenus.create({ 286 | title: 'Overwrite JSON sessions', 287 | id: 'overwrite', 288 | contexts: ['browser_action'] 289 | }); 290 | chrome.contextMenus.create({ 291 | title: 'Export as JSON', 292 | id: 'export', 293 | contexts: ['browser_action'] 294 | }); 295 | }; 296 | chrome.runtime.onStartup.addListener(onstartup); 297 | chrome.runtime.onInstalled.addListener(onstartup); 298 | } 299 | chrome.contextMenus.onClicked.addListener(info => { 300 | if (info.menuItemId === 'export') { 301 | storage.get(null).then(prefs => { 302 | const text = JSON.stringify(prefs, null, '\t'); 303 | const blob = new Blob([text], {type: 'application/json'}); 304 | const objectURL = URL.createObjectURL(blob); 305 | Object.assign(document.createElement('a'), { 306 | href: objectURL, 307 | type: 'application/json', 308 | download: 'save-tabs-sessions.json' 309 | }).dispatchEvent(new MouseEvent('click')); 310 | setTimeout(() => URL.revokeObjectURL(objectURL)); 311 | }); 312 | } 313 | else if (info.menuItemId === 'append' || info.menuItemId === 'overwrite') { 314 | chrome.windows.create({ 315 | url: 'data/drop/index.html?command=' + info.menuItemId, 316 | width: 600, 317 | height: 300, 318 | left: screen.availLeft + Math.round((screen.availWidth - 600) / 2), 319 | top: screen.availTop + Math.round((screen.availHeight - 300) / 2), 320 | type: 'popup' 321 | }); 322 | } 323 | }); 324 | 325 | /* FAQs & Feedback */ 326 | { 327 | const {management, runtime: {onInstalled, setUninstallURL, getManifest}, storage, tabs} = chrome; 328 | if (navigator.webdriver !== true) { 329 | const page = getManifest().homepage_url; 330 | const {name, version} = getManifest(); 331 | onInstalled.addListener(({reason, previousVersion}) => { 332 | management.getSelf(({installType}) => installType === 'normal' && storage.local.get({ 333 | 'faqs': true, 334 | 'last-update': 0 335 | }, prefs => { 336 | if (reason === 'install' || (prefs.faqs && reason === 'update')) { 337 | const doUpdate = (Date.now() - prefs['last-update']) / 1000 / 60 / 60 / 24 > 45; 338 | if (doUpdate && previousVersion !== version) { 339 | tabs.query({active: true, currentWindow: true}, tbs => tabs.create({ 340 | url: page + '?version=' + version + (previousVersion ? '&p=' + previousVersion : '') + '&type=' + reason, 341 | active: reason === 'install', 342 | ...(tbs && tbs.length && {index: tbs[0].index + 1}) 343 | })); 344 | storage.local.set({'last-update': Date.now()}); 345 | } 346 | } 347 | })); 348 | }); 349 | setUninstallURL(page + '?rd=feedback&name=' + encodeURIComponent(name) + '&version=' + version); 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /v3/data/popup/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (location.href.indexOf('mode=vertical') !== -1) { 4 | document.body.classList.add('vertical'); 5 | } 6 | 7 | self.counter = number => { 8 | return number; 9 | }; 10 | 11 | const prompt = document.getElementById('prompt'); 12 | const ask = (msg, type = 'password', value = '') => new Promise((resolve, reject) => { 13 | const password = prompt.querySelector('[type=password]'); 14 | const text = prompt.querySelector('[type=text]'); 15 | const callback = e => { 16 | e.preventDefault(); 17 | prompt.reject = ''; 18 | prompt.removeEventListener('submit', callback); 19 | if (type === 'password') { 20 | resolve(password.value); 21 | } 22 | else if (type === 'prompt') { 23 | resolve(text.value); 24 | } 25 | else { 26 | resolve(true); 27 | } 28 | prompt.close(); 29 | }; 30 | prompt.querySelector('span').textContent = msg; 31 | prompt.dataset.type = type; 32 | password[type === 'password' ? 'setAttribute' : 'removeAttribute']('required', true); 33 | text[type === 'prompt' ? 'setAttribute' : 'removeAttribute']('required', true); 34 | prompt.addEventListener('submit', callback); 35 | prompt.reject = reject; 36 | if (type === 'password') { 37 | password.focus(); 38 | password.value = value; 39 | } 40 | else if (type === 'prompt') { 41 | text.focus(); 42 | text.value = value; 43 | } 44 | prompt.showModal(); 45 | }); 46 | prompt.addEventListener('click', ({target}) => { 47 | if (target === prompt) { 48 | prompt.close(); 49 | if (prompt.reject) { 50 | prompt.reject(); 51 | } 52 | } 53 | }); 54 | prompt.querySelector('input[type=button]').addEventListener('click', () => { 55 | prompt.dispatchEvent(new Event('click')); 56 | }); 57 | prompt.addEventListener('keydown', e => { 58 | if (e.code === 'Escape') { 59 | e.preventDefault(); 60 | e.stopPropagation(); 61 | prompt.close(); 62 | } 63 | }); 64 | 65 | document.addEventListener('click', async e => { 66 | const target = e.target; 67 | const method = target.dataset.cmd; 68 | if (method === 'remove') { 69 | await ask('The session data will be erased. Are you sure?', 'confirm'); 70 | 71 | const div = target.closest('div[data-session]'); 72 | const {session} = div.dataset; 73 | 74 | chrome.storage.sync.get({ 75 | sessions: [] 76 | }, p1 => { 77 | chrome.storage.local.get({ 78 | sessions: [] 79 | }, p2 => { 80 | const next = minus => { 81 | document.body.dataset.count = p1.sessions.length + p2.sessions.length - minus; 82 | div.remove(); 83 | }; 84 | 85 | if (p1.sessions.includes(session)) { 86 | chrome.storage.sync.set({ 87 | sessions: p1.sessions.filter(a => a !== session) 88 | }, () => chrome.storage.sync.remove(session, () => { 89 | next(1); 90 | })); 91 | } 92 | else { 93 | if (p2.sessions.includes(session)) { 94 | chrome.storage.local.set({ 95 | sessions: p2.sessions.filter(a => a !== session) 96 | }, () => chrome.storage.local.remove(session, () => { 97 | next(1); 98 | })); 99 | } 100 | else { 101 | next(0); 102 | } 103 | } 104 | }); 105 | }); 106 | } 107 | else if (method === 'rename') { 108 | const div = target.closest('div[data-session]'); 109 | const type = div.dataset.synced === 'true' ? 'sync' : 'local'; 110 | const session = div.dataset.session; 111 | const e = div.querySelector('span'); 112 | chrome.storage[type].get({ 113 | [session]: {} 114 | }).then(async prefs => { 115 | const name = await ask('Rename this session?', 'prompt', e.title); 116 | 117 | if (name) { 118 | prefs[session].name = name; 119 | e.title = e.textContent = name; 120 | chrome.storage[type].set(prefs); 121 | } 122 | }); 123 | } 124 | else if (method === 'preview') { 125 | const div = target.closest('div[data-session]'); 126 | const {locked, session, permanent} = div.dataset; 127 | 128 | const password = locked === 'true' ? await ask('Enter the Session Password', 'password') : ''; 129 | 130 | const dialog = document.getElementById('popup'); 131 | const iframe = dialog.querySelector('iframe'); 132 | iframe.addEventListener('load', () => chrome.runtime.sendMessage({ 133 | method, 134 | session, 135 | password 136 | }, tabs => { 137 | if (Array.isArray(tabs)) { 138 | iframe.contentWindow.build({ 139 | tabs, 140 | password, 141 | session, 142 | permanent: permanent === 'true', 143 | div 144 | }); 145 | } 146 | else { 147 | dialog.close(); 148 | delete iframe.onload; 149 | } 150 | }), { 151 | once: true 152 | }); 153 | dialog.showModal(); 154 | iframe.src = '/data/editor/index.html'; 155 | } 156 | else if (method === 'restore') { 157 | const div = target.closest('div[data-session]'); 158 | const {locked, session, permanent} = div.dataset; 159 | chrome.runtime.sendMessage({ 160 | method, 161 | session, 162 | password: locked === 'true' ? await ask('Enter the Session Password', 'password') : '', 163 | remove: e.shiftKey === false, 164 | single: document.getElementById('single').checked, 165 | discard: document.getElementById('discard').checked, 166 | clean: document.getElementById('clean').checked 167 | }, () => { 168 | if (permanent !== 'true') { 169 | window.close(); 170 | div.remove(); 171 | } 172 | }); 173 | } 174 | else if (method && method.startsWith('save-')) { 175 | const dialog = document.getElementById('popup'); 176 | const iframe = dialog.querySelector('iframe'); 177 | iframe.src = '/data/dialog/index.html?method=' + method + '&silent=' + document.getElementById('silent').checked; 178 | dialog.showModal(); 179 | } 180 | else if (method === 'overwrite') { 181 | await ask('Your session will be overwritten by open tabs. Are you sure?', 'confirm'); 182 | e.target.classList.add('disabled'); 183 | const div = target.closest('div[data-session]'); 184 | const {session} = div.dataset; 185 | chrome.storage.local.get({ 186 | 'pinned': true, 187 | 'internal': false 188 | }, prefs => chrome.runtime.sendMessage({ 189 | method: 'overwrite', 190 | session, 191 | rule: 'save-tabs', 192 | ...prefs 193 | }, o => { 194 | if ('error' in o) { 195 | alert(o.error); 196 | } 197 | else { 198 | e.target.classList.remove('disabled'); 199 | if (o.count) { 200 | e.target.closest('[data-session]').querySelector('[data-id=count]').textContent = self.counter(o.count); 201 | } 202 | } 203 | })); 204 | } 205 | else if (method) { 206 | chrome.runtime.sendMessage({ 207 | method 208 | }); 209 | } 210 | }); 211 | 212 | const format = num => { 213 | const d = new Date(num); 214 | const yyyy = d.getFullYear().toString(); 215 | 216 | return `${yyyy.substr(-2)}.${('00' + (d.getMonth() + 1)).substr(-2)}.${('00' + d.getDate()).substr(-2)} ` + 217 | `${('00' + d.getHours()).substr(-2)}:${('00' + d.getMinutes()).substr(-2)}`; 218 | }; 219 | 220 | const build = () => chrome.storage.sync.get(null, p1 => { 221 | p1.sessions = p1.sessions || []; 222 | chrome.storage.local.get(null, p2 => { 223 | p2.sessions = p2.sessions || []; 224 | 225 | const sessions = document.getElementById('sessions'); 226 | sessions.textContent = ''; 227 | const f = document.createDocumentFragment(); 228 | 229 | const a = [...p1.sessions, ...p2.sessions]; 230 | a.sort((a, b) => { 231 | const oa = p1[a] || p2[a] || {}; 232 | const ob = p1[b] || p2[b] || {}; 233 | 234 | return oa.timestamp - ob.timestamp; 235 | }); 236 | 237 | document.body.dataset.count = a.length; 238 | 239 | // preview 240 | { 241 | const p1 = document.createElement('span'); 242 | p1.textContent = 'Name'; 243 | const p3 = document.createElement('span'); 244 | p3.textContent = 'Permanent'; 245 | p3.title = `Y: Permanent Session 246 | N: Temporary Session`; 247 | const p2 = document.createElement('span'); 248 | p2.textContent = 'Storage'; 249 | p2.title = `S: Stored in the synced storage 250 | L: Stored in the local storage`; 251 | const p4 = document.createElement('span'); 252 | p4.textContent = 'Locked'; 253 | p4.title = `Session is password protected or not`; 254 | const p5 = document.createElement('span'); 255 | p5.textContent = 'Tabs'; 256 | const p6 = document.createElement('span'); 257 | p6.textContent = '-'; 258 | const p7 = document.createElement('span'); 259 | p7.textContent = 'Date'; 260 | const p8 = document.createElement('span'); 261 | p8.textContent = '-'; 262 | const p9 = document.createElement('span'); 263 | p9.textContent = '-'; 264 | const p10 = document.createElement('span'); 265 | p10.textContent = '-'; 266 | 267 | const div = document.createElement('div'); 268 | div.classList.add('header'); 269 | div.append(p1, p2, p3, p4, p5, p6, p7, p8, p9, p10); 270 | sessions.appendChild(div); 271 | } 272 | 273 | a.forEach(session => { 274 | const obj = p1[session] || p2[session]; 275 | if (!obj) { 276 | console.info('session skipped', session, p1, p2); 277 | return; 278 | } 279 | 280 | // fix password protected 281 | obj.protected = obj.json.startsWith('data:application/octet-binary;'); 282 | 283 | const div = document.createElement('div'); 284 | div.dataset.session = session; 285 | div.dataset.locked = obj.protected; 286 | div.dataset.synced = p1.sessions.includes(session); 287 | const name = document.createElement('span'); 288 | name.textContent = obj.name || session.replace(/^session\./, ''); 289 | name.dataset.session = session; 290 | name.dataset.cmd = 'restore'; 291 | name.title = name.textContent; 292 | if (!obj.permanent) { 293 | name.title += ` 294 | 295 | Shift + click: restore without removing the session`; 296 | } 297 | div.appendChild(name); 298 | 299 | const synced = document.createElement('span'); 300 | synced.dataset.id = 'synced'; 301 | synced.textContent = p1.sessions.includes(session) ? 'S' : 'L'; 302 | synced.title = p1.sessions.includes(session) ? 'Stored in the synced storage' : 'Stored in the local storage'; 303 | div.appendChild(synced); 304 | 305 | const permanent = document.createElement('span'); 306 | permanent.dataset.id = 'permanent'; 307 | permanent.textContent = obj.permanent ? 'Y' : 'N'; 308 | permanent.title = obj.permanent ? 'This session is permanent' : 'This session is temporary'; 309 | div.appendChild(permanent); 310 | 311 | const lock = document.createElement('span'); 312 | lock.dataset.id = 'lock'; 313 | lock.title = obj.protected ? 'This session is password protected' : 'This session is not protected'; 314 | div.appendChild(lock); 315 | const number = document.createElement('span'); 316 | number.dataset.id = 'count'; 317 | number.textContent = self.counter(obj.tabs); 318 | div.appendChild(number); 319 | const preview = document.createElement('span'); 320 | preview.dataset.cmd = 'preview'; 321 | preview.title = 'Preview this Session'; 322 | div.appendChild(preview); 323 | const date = document.createElement('span'); 324 | date.textContent = format(obj.timestamp); 325 | div.appendChild(date); 326 | const overwrite = document.createElement('span'); 327 | overwrite.dataset.cmd = 'overwrite'; 328 | overwrite.title = 'Overwrite this Session with Open Tabs'; 329 | div.appendChild(overwrite); 330 | const rename = document.createElement('span'); 331 | rename.dataset.cmd = 'rename'; 332 | rename.title = 'Rename this Session'; 333 | div.appendChild(rename); 334 | const close = document.createElement('span'); 335 | close.dataset.cmd = 'remove'; 336 | close.title = 'Remove this Session'; 337 | div.appendChild(close); 338 | div.dataset.permanent = obj.permanent; 339 | f.appendChild(div); 340 | }); 341 | sessions.appendChild(f); 342 | sessions.scrollTop = sessions.scrollHeight; 343 | }); 344 | }); 345 | window.build = build; 346 | document.addEventListener('DOMContentLoaded', build); 347 | 348 | // persist 349 | document.addEventListener('DOMContentLoaded', () => chrome.storage.local.get({ 350 | 'silent': false, 351 | 'single': false, 352 | 'discard': false, 353 | 'clean': false 354 | }, prefs => { 355 | document.getElementById('silent').checked = prefs.silent; 356 | document.getElementById('single').checked = prefs.single; 357 | document.getElementById('normal').checked = prefs.single === false; 358 | document.getElementById('discard').checked = prefs.discard; 359 | document.getElementById('clean').checked = prefs.clean; 360 | })); 361 | document.getElementById('silent').addEventListener('change', e => chrome.storage.local.set({ 362 | 'silent': e.target.checked 363 | })); 364 | document.getElementById('manager').addEventListener('change', () => chrome.storage.local.set({ 365 | 'single': document.getElementById('single').checked, 366 | 'discard': document.getElementById('discard').checked, 367 | 'clean': document.getElementById('clean').checked 368 | })); 369 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /v3/worker.js: -------------------------------------------------------------------------------- 1 | /* global safe */ 2 | 3 | if (typeof importScripts !== 'undefined') { 4 | self.importScripts('safe.js'); 5 | } 6 | 7 | const notify = message => chrome.notifications.create({ 8 | type: 'basic', 9 | title: chrome.runtime.getManifest().name, 10 | message, 11 | iconUrl: '/data/icons/48.png' 12 | }, id => { 13 | setTimeout(() => chrome.notifications.clear(id), 5000); 14 | }); 15 | 16 | chrome.runtime.onMessage.addListener((request, sender, response) => { 17 | if (request.method === 'store' || request.method === 'overwrite') { 18 | recording.perform(request, response, request.method === 'store' ? 'new' : 'update'); 19 | return true; 20 | } 21 | else if (request.method === 'update') { 22 | recording.disk(request.tabs, request, 'update'); 23 | response(true); 24 | } 25 | else if (request.method === 'restore' || request.method === 'preview') { 26 | const next = async (session, sessions, type) => { 27 | session.protected = session.json.startsWith('data:application/octet-binary;'); 28 | try { 29 | const content = session.protected ? await safe.decrypt(session.json, request.password) : session.json; 30 | let tabs; 31 | try { 32 | tabs = JSON.parse(content); 33 | } 34 | // backup plan for old version of "safe.js" that does not handle non-printable characters 35 | catch (e) { 36 | tabs = JSON.parse(content.replace(/[^\x20-\x7E]/g, '')); 37 | } 38 | 39 | if (request.method === 'preview') { 40 | return response(tabs); 41 | } 42 | // remove currents 43 | const removeTabs = []; 44 | if (request.clean) { 45 | await chrome.tabs.query({}).then(tabs => removeTabs.push(...tabs)); 46 | } 47 | // restore 48 | const create = async (tab, props) => { 49 | const discarded = request.discard && tab.active !== true; 50 | if (/Firefox/.test(navigator.userAgent)) { 51 | props = {...props, discarded, url: tab.url}; 52 | 53 | if ('cookieStoreId' in props) { 54 | const v = await chrome.permissions.contains({ 55 | permissions: ['cookies'] 56 | }); 57 | if (v === false) { 58 | delete props.cookieStoreId; 59 | } 60 | } 61 | 62 | if (discarded) { 63 | props.title = tab.title; 64 | } 65 | return chrome.tabs.create(props); 66 | } 67 | else { 68 | let url = tab.url || 'about:blank'; 69 | if (discarded && url.startsWith('http')) { 70 | url = chrome.runtime.getURL('/data/discard/index.html?href=' + 71 | encodeURIComponent(tab.url)) + '&title=' + encodeURIComponent(tab.title); 72 | } 73 | // in case of importing from Firefox 74 | delete props.cookieStoreId; 75 | return chrome.tabs.create({ 76 | ...props, 77 | url 78 | }); 79 | } 80 | }; 81 | const groups = {}; 82 | const groupp = {}; 83 | if (request.single) { 84 | for (const t of tabs) { 85 | const props = { 86 | pinned: t.pinned, 87 | active: t.active 88 | }; 89 | if ('cookieStoreId' in t) { 90 | props.cookieStoreId = t.cookieStoreId; 91 | } 92 | try { 93 | const tab = await create(t, props); 94 | if ('groupId' in t) { 95 | groups[t.groupId] = groups[t.groupId] || []; 96 | groups[t.groupId].push(tab.id); 97 | groupp[t.groupId] = t.group; 98 | } 99 | } 100 | catch (e) { 101 | console.error('[error]', e); 102 | } 103 | } 104 | } 105 | else { 106 | const windows = {}; 107 | tabs.forEach(t => { 108 | windows[t.windowId] = windows[t.windowId] || []; 109 | windows[t.windowId].push(t); 110 | }); 111 | // sort 112 | Object.keys(windows).forEach(id => windows[id].sort((a, b) => a.index - b.index)); 113 | // restore 114 | for (const id of Object.keys(windows)) { 115 | const tab = windows[id][0]; 116 | const props = { 117 | incognito: tab.incognito 118 | }; 119 | if ('window' in tab && tab.window.width) { 120 | props.left = tab.window.left; 121 | props.top = tab.window.top; 122 | props.width = tab.window.width; 123 | props.height = tab.window.height; 124 | } 125 | const win = await chrome.windows.create(props); 126 | 127 | const toberemoved = win.tabs; 128 | for (const t of windows[id]) { 129 | const props = { 130 | pinned: t.pinned, 131 | active: t.active, 132 | windowId: win.id, 133 | index: t.index 134 | }; 135 | if ('cookieStoreId' in t) { 136 | props.cookieStoreId = t.cookieStoreId; 137 | } 138 | try { 139 | const tab = await create(t, props); 140 | if ('groupId' in t) { 141 | groups[t.groupId] = groups[t.groupId] || []; 142 | groups[t.groupId].windowId = win.id; 143 | groups[t.groupId].push(tab.id); 144 | groupp[t.groupId] = t.group; 145 | } 146 | } 147 | catch (e) { 148 | console.error('[error]', e); 149 | } 150 | } 151 | for (const {id} of toberemoved) { 152 | chrome.tabs.remove(id); 153 | } 154 | } 155 | } 156 | if ('group' in chrome.tabs) { 157 | for (let groupId of Object.keys(groups)) { 158 | groupId = Number(groupId); 159 | if (isNaN(groupId) === false && groupId > -1) { 160 | chrome.tabs.group({ 161 | createProperties: { 162 | windowId: groups[groupId].windowId 163 | }, 164 | tabIds: groups[groupId] 165 | }, id => { 166 | const p = groupp[groupId]; 167 | if (p) { 168 | chrome.tabGroups.update(id, p); 169 | } 170 | }); 171 | } 172 | } 173 | } 174 | if (request.remove && session.permanent !== true) { 175 | await chrome.storage[type].set({ 176 | sessions: sessions.filter(a => a !== request.session) 177 | }); 178 | await chrome.storage[type].remove(request.session); 179 | } 180 | if (removeTabs.length) { 181 | chrome.tabs.remove(removeTabs.map(t => t.id)); 182 | } 183 | } 184 | catch (e) { 185 | console.error(e); 186 | notify('Cannot restore tabs. Wrong password or ' + e.message); 187 | response(false); 188 | } 189 | }; 190 | 191 | chrome.storage.sync.get({ 192 | 'sessions': [] 193 | }, prefs => { 194 | if (prefs.sessions.includes(request.session)) { 195 | chrome.storage.sync.get({ 196 | [request.session]: {} 197 | }, ps => { 198 | next(ps[request.session], prefs.sessions, 'sync'); 199 | }); 200 | } 201 | else { 202 | chrome.storage.local.get({ 203 | [request.session]: {} 204 | }, ps => { 205 | next(ps[request.session], prefs.sessions, 'local'); 206 | }); 207 | } 208 | }); 209 | 210 | return request.method === 'restore' ? false : true; 211 | } 212 | }); 213 | 214 | // recording 215 | const recording = { 216 | async disk(tabs, request, type = 'new') { 217 | const map = new Map(); 218 | for (const tab of tabs) { 219 | map.set(tab.windowId, tab.window); 220 | } 221 | 222 | for (const [windowId, oldWin] of map.entries()) { 223 | // what if we are sorting old list (tabs are not accessible anymore) 224 | const win = await chrome.windows.get(windowId).catch(() => (oldWin || {})); 225 | map.set(windowId, win); 226 | } 227 | 228 | const o = []; 229 | for (const t of tabs) { 230 | let url = t.url; 231 | if (url.startsWith('chrome-extension://') && url.includes(chrome.runtime.id)) { 232 | url = (new URLSearchParams(url.split('?')[1])).get('href'); 233 | } 234 | const win = map.get(t.windowId); 235 | const me = { 236 | url, 237 | title: t.title, 238 | active: t.active, 239 | pinned: t.pinned, 240 | incognito: t.incognito, 241 | index: t.index, 242 | windowId: t.windowId, 243 | window: { 244 | focused: win.focused, 245 | type: win.type, 246 | left: win.left, 247 | top: win.top, 248 | width: win.width, 249 | height: win.height 250 | }, 251 | cookieStoreId: t.cookieStoreId, 252 | groupId: t.groupId 253 | }; 254 | if (me.groupId && me.groupId > -1) { 255 | try { 256 | const p = await chrome.tabGroups.get(me.groupId); 257 | me.group = { 258 | collapsed: p.collapsed, 259 | title: p.title, 260 | color: p.color 261 | }; 262 | } 263 | catch (e) {} 264 | } 265 | o.push(me); 266 | } 267 | let json = JSON.stringify(o); 268 | 269 | if (request.password) { 270 | json = await safe.encrypt(json, request.password); 271 | } 272 | const name = type === 'new' ? 'session.' + request.name : request.session; 273 | 274 | const psync = await chrome.storage.sync.get({ 275 | sessions: [] 276 | }); 277 | const plocal = await chrome.storage.local.get({ 278 | sessions: [] 279 | }); 280 | 281 | const no = { 282 | timestamp: Date.now() 283 | }; 284 | 285 | if (psync.sessions.includes(name)) { 286 | const o = (await chrome.storage.sync.get({ 287 | [name]: {} 288 | }))[name]; 289 | 290 | Object.assign(no, o); 291 | } 292 | else if (plocal.sessions.includes(name)) { 293 | const o = (await chrome.storage.local.get({ 294 | [name]: {} 295 | }))[name]; 296 | 297 | Object.assign(no, o); 298 | } 299 | 300 | Object.assign(no, { 301 | json, 302 | tabs: tabs.length 303 | }); 304 | if (type === 'new') { 305 | Object.assign(no, { 306 | permanent: request.permanent, 307 | protected: Boolean(request.password), 308 | query: { 309 | rule: request.rule, 310 | pinned: request.pinned, 311 | internal: request.internal 312 | } 313 | }); 314 | } 315 | 316 | // try to store in the synced storage if failed, store in the local storage 317 | try { 318 | await chrome.storage.sync.set({ 319 | [name]: no 320 | }); 321 | // stored before in the wrong storage 322 | if (plocal.sessions.includes(name)) { 323 | const n = plocal.sessions.indexOf(name); 324 | plocal.sessions.splice(n, 1); 325 | 326 | await chrome.storage.local.remove(name); 327 | await chrome.storage.local.set({ 328 | sessions: plocal.sessions 329 | }); 330 | } 331 | 332 | if (psync.sessions.includes(name) === false) { 333 | await chrome.storage.sync.set({ 334 | sessions: [...psync.sessions, name] 335 | }); 336 | } 337 | return { 338 | count: tabs.length, 339 | storage: 'sync', 340 | new: false, 341 | origin: 'local.6' 342 | }; 343 | } 344 | catch (e) { 345 | await chrome.storage.local.set({ 346 | [name]: no 347 | }); 348 | // already stored in the wrong storage 349 | if (psync.sessions.includes(name)) { 350 | const n = psync.sessions.indexOf(name); 351 | psync.sessions.splice(n, 1); 352 | 353 | await chrome.storage.sync.set({ 354 | sessions: psync.sessions 355 | }); 356 | await chrome.storage.sync.remove(name); 357 | } 358 | if (plocal.sessions.includes(name) === false) { 359 | await chrome.storage.local.set({ 360 | sessions: [...plocal.sessions, name] 361 | }); 362 | } 363 | return { 364 | count: tabs.length, 365 | storage: 'local', 366 | new: false, 367 | origin: 'sync.1' 368 | }; 369 | } 370 | }, 371 | perform(request, response, type = 'new') { 372 | const props = { 373 | windowType: 'normal' 374 | }; 375 | if (request.rule.startsWith('save-window')) { 376 | props.currentWindow = true; 377 | } 378 | if (request.rule.startsWith('save-selected')) { 379 | props.highlighted = true; 380 | } 381 | if (request.rule.startsWith('save-other-windows')) { 382 | props.currentWindow = false; 383 | } 384 | if (request.pinned === false) { 385 | props.pinned = false; 386 | } 387 | chrome.tabs.query(props, async tabs => { 388 | try { 389 | if (request.internal !== true) { 390 | tabs = tabs.filter( 391 | ({url}) => url && 392 | url.startsWith('file://') === false && 393 | url.startsWith('chrome://') === false && 394 | ( 395 | url.startsWith('chrome-extension://') === false || 396 | (url.startsWith('chrome-extension://') && url.includes(chrome.runtime.id)) 397 | ) && 398 | url.startsWith('moz-extension://') === false && 399 | url.startsWith('about:') === false 400 | ); 401 | } 402 | if (tabs.length === 0) { 403 | notify('nothing to save'); 404 | return response({ 405 | count: 0 406 | }); 407 | } 408 | const r = await recording.disk(tabs, request, type); 409 | if (request.rule === 'save-tabs-close') { 410 | chrome.tabs.create({ 411 | url: 'about:blank' 412 | }, () => chrome.tabs.remove(tabs.map(t => t.id))); 413 | } 414 | else if (request.rule.endsWith('-close')) { 415 | chrome.tabs.remove(tabs.map(t => t.id)); 416 | } 417 | response(r); 418 | } 419 | catch (e) { 420 | console.error(e); 421 | response({ 422 | error: e.message 423 | }); 424 | } 425 | }); 426 | } 427 | }; 428 | 429 | // context menu 430 | { 431 | const onstartup = () => { 432 | if (onstartup.done) { 433 | return; 434 | } 435 | onstartup.done = true; 436 | 437 | chrome.contextMenus.create({ 438 | title: 'Add Sessions from JSON File to Current List', 439 | id: 'append', 440 | contexts: ['action'] 441 | }); 442 | chrome.contextMenus.create({ 443 | title: 'Overwrite List with Sessions from JSON File', 444 | id: 'overwrite', 445 | contexts: ['action'] 446 | }); 447 | chrome.contextMenus.create({ 448 | title: 'Export Sessions as JSON File', 449 | id: 'export', 450 | contexts: ['action'] 451 | }); 452 | if (navigator.userAgent.includes('Firefox')) { 453 | chrome.contextMenus.create({ 454 | title: 'Permission to use containers', 455 | id: 'containers', 456 | contexts: ['action'] 457 | }); 458 | } 459 | }; 460 | chrome.runtime.onStartup.addListener(onstartup); 461 | chrome.runtime.onInstalled.addListener(onstartup); 462 | } 463 | chrome.contextMenus.onClicked.addListener(async info => { 464 | if (info.menuItemId === 'export') { 465 | const prefs = await chrome.storage.sync.get(null); 466 | prefs.sessions = prefs.sessions || []; 467 | 468 | const ps = await chrome.storage.local.get(null); 469 | if ('sessions' in ps) { 470 | for (const session of ps.sessions) { 471 | prefs[session] = ps[session] || prefs[session]; 472 | prefs.sessions.push(session); 473 | } 474 | } 475 | const text = JSON.stringify(prefs, null, ' '); 476 | if (navigator.userAgent.includes('Firefox')) { 477 | const response = new Response(text, { 478 | headers: { 479 | 'Content-Type': 'application/json' 480 | } 481 | }); 482 | const blob = await response.blob(); 483 | const blobUrl = URL.createObjectURL(blob); 484 | await chrome.downloads.download({ 485 | filename: 'save-tabs-sessions.json', 486 | url: blobUrl 487 | }); 488 | URL.revokeObjectURL(blobUrl); 489 | } 490 | else { 491 | // Dealing with characters outside of the Latin1 range. 492 | const buffer = await new Response(text).arrayBuffer(); 493 | const bytes = new Uint8Array(buffer); 494 | let binary = ''; 495 | const chunkSize = 0x8000; 496 | for (let i = 0; i < bytes.length; i += chunkSize) { 497 | binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); 498 | } 499 | 500 | chrome.downloads.download({ 501 | filename: 'save-tabs-sessions.json', 502 | url: 'data:application/json;base64,' + btoa(binary) 503 | }); 504 | } 505 | } 506 | else if (info.menuItemId === 'append' || info.menuItemId === 'overwrite') { 507 | chrome.windows.getCurrent(win => { 508 | chrome.windows.create({ 509 | url: '/data/drop/index.html?command=' + info.menuItemId, 510 | width: 600, 511 | height: 300, 512 | left: win.left + Math.round((win.width - 600) / 2), 513 | top: win.top + Math.round((win.height - 300) / 2), 514 | type: 'popup' 515 | }); 516 | }); 517 | } 518 | else if (info.menuItemId === 'containers') { 519 | chrome.permissions.request({ 520 | permissions: ['cookies'] 521 | }); 522 | } 523 | }); 524 | 525 | /* FAQs & Feedback */ 526 | { 527 | const {management, runtime: {onInstalled, setUninstallURL, getManifest}, storage, tabs} = chrome; 528 | if (navigator.webdriver !== true) { 529 | const {homepage_url: page, name, version} = getManifest(); 530 | onInstalled.addListener(({reason, previousVersion}) => { 531 | management.getSelf(({installType}) => installType === 'normal' && storage.local.get({ 532 | 'faqs': true, 533 | 'last-update': 0 534 | }, prefs => { 535 | if (reason === 'install' || (prefs.faqs && reason === 'update')) { 536 | const doUpdate = (Date.now() - prefs['last-update']) / 1000 / 60 / 60 / 24 > 45; 537 | if (doUpdate && previousVersion !== version) { 538 | tabs.query({active: true, lastFocusedWindow: true}, tbs => tabs.create({ 539 | url: page + '?version=' + version + (previousVersion ? '&p=' + previousVersion : '') + '&type=' + reason, 540 | active: reason === 'install', 541 | ...(tbs && tbs.length && {index: tbs[0].index + 1}) 542 | })); 543 | storage.local.set({'last-update': Date.now()}); 544 | } 545 | } 546 | })); 547 | }); 548 | setUninstallURL(page + '?rd=feedback&name=' + encodeURIComponent(name) + '&version=' + version); 549 | } 550 | } 551 | --------------------------------------------------------------------------------