├── .github └── workflows │ └── app-build.yml ├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── README.md ├── electron-builder.yml ├── main ├── background.ts └── helpers │ ├── create-window.ts │ ├── index.ts │ ├── user-settings.ts │ └── web-requests.ts ├── package.json ├── renderer ├── components │ ├── FlotBar.tsx │ ├── FlotControls.tsx │ ├── FlotEmbed.tsx │ └── FlotPlaceholder.tsx ├── next-env.d.ts ├── next.config.js ├── pages │ ├── _app.tsx │ ├── _document.tsx │ └── index.tsx ├── postcss.config.js ├── public │ ├── css │ │ └── embed.css │ ├── fonts │ │ ├── WorkSans-Italic.ttf │ │ └── WorkSans.ttf │ └── images │ │ └── logo │ │ ├── 1024x1024.png │ │ ├── 128x128.png │ │ ├── 16x16.png │ │ ├── 24x24.png │ │ ├── 256x256.png │ │ ├── 32x32.png │ │ ├── 48x48.png │ │ ├── 512x512.png │ │ └── 64x64.png ├── store.ts ├── styles │ └── globals.css ├── tailwind.config.js ├── tsconfig.json └── util │ └── magic-urls.ts ├── resources ├── entitlements.mac.plist ├── icon.icns ├── icon.ico ├── icon.png └── icons │ ├── 128x128.png │ ├── 16x16.png │ ├── 24x24.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 48x48.png │ ├── 512x512.png │ └── 64x64.png ├── script └── serve-website.js ├── tsconfig.json ├── website ├── fonts │ ├── WorkSans-Italic.ttf │ └── WorkSans.ttf ├── images │ ├── clouds.svg │ ├── icons │ │ ├── 128x128.png │ │ ├── 16x16.png │ │ ├── 24x24.png │ │ ├── 256x256.png │ │ ├── 32x32.png │ │ ├── 48x48.png │ │ ├── 512x512.png │ │ ├── 64x64.png │ │ └── icon.ico │ ├── screenshots │ │ ├── 01_home.png │ │ ├── 02_settings1.png │ │ ├── 03_settings2.png │ │ ├── 04_seethrough.png │ │ └── 05_detached.png │ └── ship_with_sign.png ├── index.css ├── index.html └── tailwind.config.js └── yarn.lock /.github/workflows/app-build.yml: -------------------------------------------------------------------------------- 1 | name: Build Flot App 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | defaults: 7 | run: 8 | shell: "bash" 9 | 10 | jobs: 11 | build: 12 | if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && contains(toJson(github.event.commits), '[skip ci]') == false 13 | name: cicd 14 | strategy: 15 | matrix: 16 | include: 17 | - platform: linux 18 | os: "ubuntu-latest" 19 | - platform: windows 20 | os: "windows-latest" 21 | - platform: mac 22 | os: "macos-latest" 23 | runs-on: ${{ matrix.os }} 24 | steps: 25 | - uses: actions/checkout@v2 26 | 27 | - uses: actions/setup-node@v1 28 | with: 29 | node-version: 20 30 | 31 | - name: Ensure yarn available 32 | run: | 33 | if ! command -v yarn &> /dev/null 34 | then 35 | echo "yarn could not be found, installing it" 36 | npm i -g yarn 37 | fi 38 | 39 | - name: Get yarn cache directory 40 | id: yarn-cache 41 | run: | 42 | echo "::set-output name=dir::$(yarn cache dir)" 43 | - uses: actions/cache@v2 44 | with: 45 | path: ${{ steps.yarn-cache.outputs.dir }} 46 | key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} 47 | restore-keys: | 48 | ${{ runner.os }}-node- 49 | 50 | - run: yarn install --frozen-lockfile --network-timeout 300000 51 | 52 | - run: yarn run release 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | # APPLE_ID: ${{ secrets.APPLE_ID }} 56 | # APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} 57 | # CSC_LINK: ${{ secrets.CSC_LINK }} 58 | # CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} 59 | # WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} 60 | # WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .next 4 | app 5 | dist 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Nextron: Main", 9 | "type": "node", 10 | "request": "attach", 11 | // "protocol": "inspector", 12 | "port": 9292, 13 | "skipFiles": ["/**"], 14 | "sourceMapPathOverrides": { 15 | "webpack:///./~/*": "${workspaceFolder}/node_modules/*", 16 | "webpack:///./*": "${workspaceFolder}/*", 17 | "webpack:///*": "*" 18 | }, 19 | "presentation": { "hidden": true, "group": "", "order": 2 } 20 | }, 21 | { 22 | "name": "Nextron: Renderer", 23 | "type": "chrome", 24 | "request": "attach", 25 | "port": 5858, 26 | "timeout": 10000, 27 | "urlFilter": "http://localhost:*", 28 | "webRoot": "${workspaceFolder}/app", 29 | "sourceMapPathOverrides": { 30 | "webpack:///./src/*": "${webRoot}/*" 31 | }, 32 | "presentation": { "hidden": true, "group": "", "order": 2 } 33 | } 34 | ], 35 | "compounds": [ 36 | { 37 | "name": "Nextron: All", 38 | "preLaunchTask": "dev", 39 | "configurations": ["Nextron: Main", "Nextron: Renderer"], 40 | "presentation": { "hidden": false, "group": "", "order": 1 } 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "dev", 7 | "isBackground": true, 8 | "problemMatcher": { 9 | "owner": "custom", 10 | "pattern": { 11 | "regexp": "" 12 | }, 13 | "background": { 14 | "beginsPattern": "started server", 15 | "endsPattern": "Debugger listening on" 16 | } 17 | }, 18 | "label": "dev" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Andrew Brey <34140052+andrewbrey@users.noreply.github.com> 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | 4 | Flot 5 | 6 |
7 | Flōt (floht) 8 |
9 |
10 |

11 | 12 |

Keep a website always-on-top and translucent. For Mac, Windows, and Linux.

13 | 14 |

15 | github actions status 16 | github release version 17 | github release downloads 18 |

19 | 20 | ## Install 21 | 22 | Download the latest version of Flōt for your operating system from 23 | [the official website](https://flot.page) or from 24 | [GitHub Releases](https://github.com/andrewbrey/flot/releases/latest) 25 | 26 | ## Important Notes About Usage 27 | 28 | 1. Flōt embeds a website of your choosing inside of the Flōt application. Some 29 | websites do not permit being embedded in this way by default, and Flōt 30 | bypasses this restriction by disabling certain web security mechanisms. This 31 | should be perfectly safe if you're just using it to view a video or content 32 | site, but you should exercise caution by not using Flōt as a general purpose 33 | web browser. Those protections within your browser are important to ensure 34 | you don't have private or sensitive information stolen. 35 | 36 | > TLDR; Flōt is mostly a fun toy, and should not be used like a regular browser. 37 | > Don't do your banking in a Flōt window. Don't enter passwords in a Flōt 38 | > window. Treat Flōt like it is a "read only" viewer of _public_ web pages. 39 | 40 | 2. In order to distrubute and/or auto-update an app on Windows and Mac, 41 | developers need to pay pretty large fees to become part of the respective 42 | developer programs for each platform. Since Flōt is just for fun, to scratch 43 | my own itch, I have chosen not to pay these fees for the sake of this app. 44 | This means that this app is **"unsigned"** and is therefore likely to trigger 45 | warnings from your operating system about being from an "untrusted 46 | developer". This is expected, and the instructions to get passed this will 47 | vary depending on your operating system. 48 | 49 | For mac 50 | [here is a support page to install an untrusted dmg 51 | file](https://support.apple.com/guide/mac-help/open-a-mac-app-from-an-unidentified-developer-mh40616/mac) 52 | 53 | On Windows, you should just be able to use the presented prompt to open the 54 | app 55 | 56 | While I promise this app does nothing malicious, please don't just take my 57 | word for it! The code is here for you to see and read :) 58 | 59 | > TLDR; Flōt may be flagged by your computer as an app you shouldn't open. You 60 | > can bypass this if you understand the risks. Note that for Mac, this will 61 | > prevent the app from auto-updating itself, so updates must be installed 62 | > manually. 63 | 64 | ## Features 65 | 66 | Top feature: It's simple and works like a charm ❤️ 67 | 68 | - Adjustable transparency to ensure just the right level of visibility 69 | - Stays on top of other windows and out of your way 70 | - Optional "detached" mode which will let mouse clicks "pass through" Flōt to 71 | the window below 72 | - Built-in basic ad and tracker blocking 73 | - Simple and clean design with just the cutest little logo drawn by my wife 74 | 75 | # Screenshots 76 | 77 | | | | 78 | | :-----------------------------------------------: | :----------------------------------------------------------: | 79 | | ![Home](./website/images/screenshots/01_home.png) | ![Settings 1](./website/images/screenshots/02_settings1.png) | 80 | 81 | | | | 82 | | :----------------------------------------------------------: | :------------------------------------------------------------: | 83 | | ![Settings 2](./website/images/screenshots/03_settings2.png) | ![See Through](./website/images/screenshots/04_seethrough.png) | 84 | 85 | | | 86 | | :-------------------------------------------------------: | 87 | | ![Detached](./website/images/screenshots/05_detached.png) | 88 | 89 | ## License 90 | 91 | [MIT](./LICENSE). Copyright (c) [Andrew Brey](https://andrewbrey.com) 92 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: page.flot 2 | productName: Flōt 3 | copyright: Copyright © 2021 Andrew Brey 4 | directories: 5 | output: dist 6 | buildResources: resources 7 | mac: 8 | target: dmg 9 | type: distribution 10 | artifactName: Flot.setup.${version}.${ext} 11 | hardenedRuntime: true 12 | gatekeeperAssess: false 13 | entitlements: build/entitlements.mac.plist 14 | entitlementsInherit: build/entitlements.mac.plist 15 | win: 16 | target: nsis 17 | publisherName: Andrew Brey 18 | rfc3161TimeStampServer: http://timestamp.digicert.com 19 | nsis: 20 | artifactName: Flot.setup.${version}.${ext} 21 | linux: 22 | target: AppImage 23 | category: Utility 24 | artifactName: Flot-${version}.${ext} 25 | synopsis: Open websites in a floating window 26 | desktop: 27 | Name: Flot 28 | Type: Application 29 | Categories: Utility 30 | files: 31 | - from: . 32 | filter: 33 | - package.json 34 | - app 35 | publish: 36 | provider: github 37 | owner: andrewbrey 38 | repo: flot 39 | private: false 40 | releaseType: draft 41 | -------------------------------------------------------------------------------- /main/background.ts: -------------------------------------------------------------------------------- 1 | import fetch from "cross-fetch"; 2 | import { app, BrowserWindow, globalShortcut, webFrameMain } from "electron"; 3 | import { ipcMain as ipc } from "electron-better-ipc"; 4 | import serve from "electron-serve"; 5 | import { readFile } from "fs/promises"; 6 | import { join } from "path"; 7 | import { createWindow, getUserSettings } from "./helpers"; 8 | import { omitSecurityHeadersAndBlockAds } from "./helpers/web-requests"; 9 | 10 | const isProd: boolean = process.env.NODE_ENV === "production"; 11 | const isLinux = process.platform === "linux"; 12 | 13 | const port = process.argv[2]; 14 | const devURL = `http://localhost:${port}`; 15 | 16 | let mainWindow: BrowserWindow | undefined; 17 | let userSettings = getUserSettings(); 18 | /* @ts-expect-error shut up about thing that will go away when nextron supports type="module" and esm */ 19 | let experimentalVideoCSS = userSettings.get("focusVideo", false); 20 | let forceDetachKeybindActive = false; 21 | const videoCSSOnScript = 'document.documentElement.classList.add("flot-video");'; 22 | const videoCSSOffScript = 'document.documentElement.classList.remove("flot-video");'; 23 | 24 | // TODO: can this be omitted finally? 25 | // if (isLinux) { 26 | // // https://github.com/electron/electron/issues/25153#issuecomment-843688494 27 | // app.commandLine.appendSwitch("use-gl", "desktop"); 28 | // } 29 | 30 | if (isProd) { 31 | serve({ directory: "app" }); 32 | } else { 33 | app.setPath("userData", `${app.getPath("userData")} (development)`); 34 | } 35 | 36 | (async () => { 37 | await app.whenReady(); 38 | 39 | globalShortcut.register("CmdOrCtrl+Alt+I", () => { 40 | mainWindow?.setIgnoreMouseEvents(!forceDetachKeybindActive); 41 | forceDetachKeybindActive = !forceDetachKeybindActive; 42 | }); 43 | 44 | const embedCssFilePromise = isProd 45 | ? readFile(join(__dirname, "css", "embed.css"), { encoding: "utf8" }) 46 | : fetch(`${devURL}/css/embed.css`).then((r) => r.text()); 47 | 48 | const embedCss = embedCssFilePromise.catch((e) => { 49 | console.error(e); 50 | return ""; 51 | }); 52 | 53 | await new Promise((r) => setTimeout(r, isLinux ? 750 : 350)); // required otherwise transparency won't work consistently cross-platform... 54 | 55 | mainWindow = createWindow("main", { 56 | transparent: true, 57 | title: "Flōt", 58 | titleBarStyle: undefined, 59 | trafficLightPosition: undefined, 60 | skipTaskbar: false, 61 | frame: false, 62 | maximizable: false, 63 | fullscreenable: false, 64 | enableLargerThanScreen: false, 65 | acceptFirstMouse: false, 66 | roundedCorners: true, 67 | autoHideMenuBar: true, 68 | minHeight: 200, 69 | minWidth: 300, 70 | maxHeight: 1000, 71 | maxWidth: 1000, 72 | icon: join(__dirname, "images", "logo", "256x256.png"), 73 | alwaysOnTop: true, 74 | hasShadow: false, 75 | webPreferences: { 76 | webSecurity: false, 77 | }, 78 | }); 79 | 80 | await omitSecurityHeadersAndBlockAds(mainWindow.webContents.session); 81 | 82 | mainWindow.setVisibleOnAllWorkspaces(true); 83 | 84 | mainWindow.webContents.on( 85 | "did-fail-provisional-load", 86 | (event, code, desc, validatedUrl, isMainFrame, frameProcessId, frameRoutingId) => { 87 | if (mainWindow) ipc.callRenderer(mainWindow, "iframe-load-failure", validatedUrl); 88 | } 89 | ); 90 | 91 | mainWindow.webContents.on( 92 | "did-frame-navigate", 93 | async (event, url, status, statusText, isMainFrame, frameProcessId, frameRoutingId) => { 94 | if (isMainFrame) return; 95 | if (url === "about:blank") return; 96 | 97 | const frame = webFrameMain.fromId(frameProcessId, frameRoutingId); 98 | 99 | if (frame) { 100 | frame.executeJavaScript(`document.addEventListener('DOMContentLoaded', () => { 101 | // post back to the parent app so we know we loaded (also gives us the final resolved url after redirects) 102 | parent.postMessage({key: 'location', msg: location.href}, "*") 103 | 104 | window.addEventListener('focus', () => parent.postMessage({key: 'active', msg: true}, "*")) 105 | window.addEventListener('blur', () => parent.postMessage({key: 'active', msg: false}, "*")) 106 | 107 | document.addEventListener('mouseenter', () => parent.postMessage({key: 'hover', msg: true}, "*")) 108 | document.addEventListener('mouseleave', () => parent.postMessage({key: 'hover', msg: false}, "*")) 109 | 110 | // add marker to document that it can opt into embedded css 111 | document.documentElement.classList.add("flotapp"); 112 | 113 | // prepare embed stylesheet 114 | Array.from(document.querySelectorAll('[data-flot]')).forEach(s => s.remove()); 115 | const link = document.createElement('style'); 116 | link.setAttribute('data-flot', 'true'); 117 | link.textContent = \`${await embedCss}\` 118 | document.head.appendChild(link); 119 | 120 | ${experimentalVideoCSS ? videoCSSOnScript : videoCSSOffScript} 121 | }, {once: true}) 122 | `); 123 | } 124 | } 125 | ); 126 | 127 | mainWindow.webContents.on( 128 | "did-navigate-in-page", 129 | async (event, url, isMainFrame, frameProcessId, frameRoutingId) => { 130 | if (isMainFrame) return; 131 | if (url === "about:blank") return; 132 | 133 | const frame = webFrameMain.fromId(frameProcessId, frameRoutingId); 134 | 135 | if (frame) { 136 | frame.executeJavaScript(`parent.postMessage({key: 'location', msg: location.href}, "*")`); 137 | } 138 | } 139 | ); 140 | 141 | mainWindow.webContents.setWindowOpenHandler(() => ({ action: "deny" })); 142 | 143 | if (isProd) { 144 | await mainWindow.loadURL("app://./index.html"); 145 | 146 | app 147 | .whenReady() 148 | .then(() => import("electron-updater")) 149 | .then(({ autoUpdater }) => autoUpdater.checkForUpdatesAndNotify()) 150 | .catch((e) => console.error("Failed check updates:", e)); 151 | } else { 152 | await mainWindow.loadURL(`${devURL}/`); 153 | mainWindow.webContents.openDevTools({ mode: "detach", activate: false }); 154 | } 155 | })(); 156 | 157 | ipc.answerRenderer("ask-video-css-enabled", () => experimentalVideoCSS); 158 | ipc.answerRenderer("please-enable-video-css", () => { 159 | getFlotEmbed()?.executeJavaScript(videoCSSOnScript); 160 | /* @ts-expect-error shut up about thing that will go away when nextron supports type="module" and esm */ 161 | userSettings.set("focusVideo", true); 162 | experimentalVideoCSS = true; 163 | }); 164 | ipc.answerRenderer("please-disable-video-css", () => { 165 | getFlotEmbed()?.executeJavaScript(videoCSSOffScript); 166 | /* @ts-expect-error shut up about thing that will go away when nextron supports type="module" and esm */ 167 | userSettings.set("focusVideo", false); 168 | experimentalVideoCSS = false; 169 | }); 170 | ipc.answerRenderer("please-detach", () => mainWindow?.setIgnoreMouseEvents(true)); 171 | ipc.answerRenderer("please-attach", () => { 172 | if (!forceDetachKeybindActive) mainWindow?.setIgnoreMouseEvents(false); 173 | }); 174 | ipc.answerRenderer("please-quit", () => app.quit()); 175 | 176 | app.on("window-all-closed", () => app.quit()); 177 | app.on("will-quit", () => globalShortcut.unregisterAll()); 178 | 179 | function getFlotEmbed() { 180 | const frames = mainWindow?.webContents.mainFrame.frames ?? []; 181 | 182 | // Some websites seem to cause the embed frame to be renamed...not sure how, it's 183 | // supposed to be a readonly property, but ah well, if this happens, just try the 184 | // using the first frame 185 | return frames.find((f) => f.name === "flot-embed") ?? frames[0]; 186 | } 187 | -------------------------------------------------------------------------------- /main/helpers/create-window.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, BrowserWindowConstructorOptions, Rectangle, screen } from "electron"; 2 | import Store from "electron-store"; 3 | 4 | interface WindowState { 5 | x: number; 6 | y: number; 7 | width: any; 8 | height: any; 9 | } 10 | 11 | export default (windowName: string, options: BrowserWindowConstructorOptions): BrowserWindow => { 12 | const key = "window-state"; 13 | const name = `window-state-${windowName}`; 14 | const defaultState: WindowState = { 15 | x: 0, 16 | y: 0, 17 | width: options.width ?? 600, 18 | height: options.height ?? 400, 19 | }; 20 | const store = new Store({ 21 | name, 22 | defaults: { ...defaultState }, 23 | }); 24 | 25 | let state = {}; 26 | let win: BrowserWindow | undefined; 27 | 28 | /* @ts-expect-error shut up about thing that will go away when nextron supports type="module" and esm */ 29 | const restore = (): WindowState => store.get(key, { ...defaultState }); 30 | 31 | const getCurrentPosition = (): WindowState => { 32 | const position = win?.getPosition() ?? [0, 0]; 33 | const size = win?.getSize() ?? [defaultState.width, defaultState.height]; 34 | return { 35 | x: position[0], 36 | y: position[1], 37 | width: size[0], 38 | height: size[1], 39 | }; 40 | }; 41 | 42 | const windowWithinBounds = (windowState: WindowState, bounds: Rectangle) => { 43 | return ( 44 | windowState.x >= bounds.x && 45 | windowState.y >= bounds.y && 46 | windowState.x + windowState.width <= bounds.x + bounds.width && 47 | windowState.y + windowState.height <= bounds.y + bounds.height 48 | ); 49 | }; 50 | 51 | const resetToDefaults = (): WindowState => { 52 | const bounds = screen.getPrimaryDisplay().bounds; 53 | return Object.assign({}, defaultState, { 54 | x: (bounds.width - defaultState.width) / 2, 55 | y: (bounds.height - defaultState.height) / 2, 56 | }); 57 | }; 58 | 59 | const ensureVisibleOnSomeDisplay = (windowState: WindowState) => { 60 | const visible = screen.getAllDisplays().some((display) => { 61 | return windowWithinBounds(windowState, display.bounds); 62 | }); 63 | if (!visible) { 64 | // Window is partially or fully not visible now. 65 | // Reset it to safe defaults. 66 | return resetToDefaults(); 67 | } 68 | return windowState; 69 | }; 70 | 71 | const saveState = () => { 72 | if (!win?.isMinimized() && !win?.isMaximized()) { 73 | Object.assign(state, getCurrentPosition()); 74 | } 75 | /* @ts-expect-error shut up about thing that will go away when nextron supports type="module" and esm */ 76 | store.set(key, state); 77 | }; 78 | 79 | state = ensureVisibleOnSomeDisplay(restore()); 80 | 81 | const browserOptions: BrowserWindowConstructorOptions = { 82 | ...options, 83 | ...state, 84 | webPreferences: { 85 | nodeIntegration: true, 86 | contextIsolation: false, 87 | ...options.webPreferences, 88 | }, 89 | }; 90 | win = new BrowserWindow(browserOptions); 91 | 92 | win.on("close", saveState); 93 | 94 | return win; 95 | }; 96 | -------------------------------------------------------------------------------- /main/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import createWindow from "./create-window"; 2 | import getUserSettings from "./user-settings"; 3 | 4 | export { createWindow, getUserSettings }; 5 | -------------------------------------------------------------------------------- /main/helpers/user-settings.ts: -------------------------------------------------------------------------------- 1 | import Store from "electron-store"; 2 | 3 | interface UserSettings { 4 | focusVideo: boolean; 5 | } 6 | 7 | export default () => { 8 | const name = `user-settings`; 9 | const defaultState: UserSettings = { 10 | focusVideo: false, 11 | }; 12 | const store = new Store({ 13 | name, 14 | defaults: { ...defaultState }, 15 | }); 16 | 17 | return store; 18 | }; 19 | -------------------------------------------------------------------------------- /main/helpers/web-requests.ts: -------------------------------------------------------------------------------- 1 | import { ElectronBlocker, fullLists, Request } from "@cliqz/adblocker-electron"; 2 | import fetch from "cross-fetch"; 3 | import { app, Session } from "electron"; 4 | import { readFile, writeFile } from "fs/promises"; 5 | import { omitBy, toLower } from "lodash"; 6 | import path from "path"; 7 | 8 | const verbose = false; // change to true for request block logs 9 | const engineCacheVersion = 3; 10 | 11 | // Extra sources thanks to https://github.com/th-ch/youtube-music/blob/master/src/plugins/adblocker/blocker.ts 12 | const SOURCES = [ 13 | "https://raw.githubusercontent.com/kbinani/adblock-youtube-ads/master/signed.txt", 14 | // UBlock Origin 15 | "https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters.txt", 16 | "https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/quick-fixes.txt", 17 | "https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/unbreak.txt", 18 | "https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2020.txt", 19 | "https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2021.txt", 20 | "https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2022.txt", 21 | "https://raw.githubusercontent.com/ghostery/adblocker/master/packages/adblocker/assets/ublock-origin/filters-2023.txt", 22 | // Fanboy Annoyances 23 | "https://secure.fanboy.co.nz/fanboy-annoyance_ubo.txt", 24 | // AdGuard 25 | "https://filters.adtidy.org/extension/ublock/filters/122_optimized.txt", 26 | ]; 27 | 28 | export async function omitSecurityHeadersAndBlockAds(session: Session) { 29 | const cachingOptions = { 30 | path: path.resolve(app.getPath("userData"), `ad-blocker-engine-v${engineCacheVersion}.bin`), 31 | read: readFile, 32 | write: writeFile, 33 | }; 34 | 35 | const blocker = await ElectronBlocker.fromLists( 36 | fetch, 37 | [...fullLists, ...SOURCES], 38 | { loadNetworkFilters: true }, 39 | cachingOptions 40 | ); 41 | blocker.enableBlockingInSession(session); 42 | 43 | session.webRequest.onHeadersReceived({ urls: ["*://*/*"] }, (details, callback) => { 44 | const responseHeaders = omitBy({ ...(details?.responseHeaders ?? {}) }, (value, key) => 45 | ["x-frame-options", "content-security-policy"].includes(toLower(key)) 46 | ); 47 | 48 | callback({ cancel: false, responseHeaders }); 49 | }); 50 | 51 | if (process.env.NODE_ENV === "development" && verbose) { 52 | blocker.on("request-allowed", (request: Request) => { 53 | console.log("allowed", request.tabId, request.url); 54 | }); 55 | 56 | blocker.on("request-blocked", (request: Request) => { 57 | console.log("blocked", request.tabId, request.url); 58 | }); 59 | 60 | blocker.on("request-redirected", (request: Request) => { 61 | console.log("redirected", request.tabId, request.url); 62 | }); 63 | 64 | blocker.on("request-whitelisted", (request: Request) => { 65 | console.log("whitelisted", request.tabId, request.url); 66 | }); 67 | 68 | blocker.on("csp-injected", (request: Request) => { 69 | console.log("csp", request.url); 70 | }); 71 | 72 | blocker.on("script-injected", (script: string, url: string) => { 73 | console.log("script", script.length, url); 74 | }); 75 | 76 | blocker.on("style-injected", (style: string, url: string) => { 77 | console.log("style", style.length, url); 78 | }); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "Flot", 4 | "description": "Open websites in a floating window", 5 | "version": "3.0.0", 6 | "author": { 7 | "name": "Andrew Brey", 8 | "email": "34140052+andrewbrey@users.noreply.github.com" 9 | }, 10 | "homepage": "https://flot.page", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/andrewbrey/flot.git" 15 | }, 16 | "main": "app/background.js", 17 | "scripts": { 18 | "dev": "nextron", 19 | "tw": "npx tailwindcss -i ./renderer/styles/globals.css -c ./website/tailwind.config.js -o ./website/index.css --watch --minify", 20 | "srv": "node script/serve-website.js", 21 | "web": "NODE_ENV=production concurrently --kill-others npm:tw npm:srv", 22 | "build": "nextron build", 23 | "release": "nextron build --publish always", 24 | "postinstall": "electron-builder install-app-deps" 25 | }, 26 | "dependencies": { 27 | "@cliqz/adblocker-electron": "1.33.2", 28 | "@headlessui/react": "2.1.8", 29 | "@heroicons/react": "2.1.5", 30 | "classnames": "2.5.1", 31 | "cross-fetch": "4.0.0", 32 | "electron-better-ipc": "2.0.1", 33 | "electron-updater": "6.3.4", 34 | "framer-motion": "11.5.4", 35 | "lodash": "4.17.21", 36 | "react-use": "17.5.1", 37 | "zustand": "4.5.5" 38 | }, 39 | "devDependencies": { 40 | "@tailwindcss/forms": "0.5.9", 41 | "@types/lodash": "4.17.7", 42 | "@types/node": "^22.5.5", 43 | "@types/node-fetch": "2.6.11", 44 | "@types/react": "^18.3.7", 45 | "autoprefixer": "^10.4.20", 46 | "concurrently": "9.0.1", 47 | "electron": "^32.1.1", 48 | "electron-builder": "^25.0.5", 49 | "electron-serve": "^2.1.1", 50 | "electron-store": "^10.0.0", 51 | "live-server": "1.2.2", 52 | "next": "^14.2.12", 53 | "nextron": "^9.1.0", 54 | "postcss": "^8.4.47", 55 | "react": "^18.3.1", 56 | "react-dom": "^18.3.1", 57 | "tailwindcss": "^3.4.12", 58 | "typescript": "^5.6.2" 59 | }, 60 | "browserslist": [ 61 | "Electron >= 32" 62 | ] 63 | } 64 | -------------------------------------------------------------------------------- /renderer/components/FlotBar.tsx: -------------------------------------------------------------------------------- 1 | import { XMarkIcon } from "@heroicons/react/24/solid"; 2 | import cn from "classnames"; 3 | import { ipcRenderer as ipc } from "electron-better-ipc"; 4 | import React from "react"; 5 | import { useStore } from "../store"; 6 | 7 | function FlotBar() { 8 | const url = useStore((state) => state.url); 9 | const windowActive = useStore((state) => state.windowActive); 10 | const windowHover = useStore((state) => state.windowHover); 11 | const childActive = useStore((state) => state.childActive); 12 | const childHover = useStore((state) => state.childHover); 13 | 14 | const quit = async () => ipc.callMain("please-quit"); 15 | 16 | return ( 17 | 18 | 61 | 62 | ); 63 | } 64 | 65 | export default FlotBar; 66 | -------------------------------------------------------------------------------- /renderer/components/FlotControls.tsx: -------------------------------------------------------------------------------- 1 | import { Disclosure, Transition } from "@headlessui/react"; 2 | import cn from "classnames"; 3 | import { ipcRenderer as ipc } from "electron-better-ipc"; 4 | import { motion } from "framer-motion"; 5 | import { debounce } from "lodash"; 6 | import React, { ChangeEventHandler, ClipboardEventHandler, KeyboardEventHandler } from "react"; 7 | import { useStore } from "../store"; 8 | 9 | function FlotControls() { 10 | const urlInput = React.useRef(null); 11 | const opacityInput = React.useRef(null); 12 | 13 | const url = useStore((state) => state.url); 14 | const storeOpacity = useStore((state) => Math.round(state.opacity * 100)); 15 | const setStoreOpacity = useStore((state) => state.setOpacity); 16 | const setURL = useStore((state) => state.setUrl); 17 | const urlLocked = useStore((state) => state.urlLocked); 18 | const windowActive = useStore((state) => state.windowActive); 19 | const windowHover = useStore((state) => state.windowHover); 20 | const childActive = useStore((state) => state.childActive); 21 | const childHover = useStore((state) => state.childHover); 22 | 23 | const [focusVideo, setFocusVideo] = React.useState(false); 24 | const [detach, setDetach] = React.useState(false); 25 | const [opacity, setOpacity] = React.useState(1); 26 | 27 | React.useEffect(() => { 28 | ipc 29 | .callMain("ask-video-css-enabled") 30 | .then((videoHasFocus) => setFocusVideo(videoHasFocus)); 31 | }, []); 32 | 33 | React.useEffect(() => setOpacity(storeOpacity), [storeOpacity]); 34 | 35 | React.useEffect(() => { 36 | if (urlInput.current) 37 | urlInput.current.value = url.replace(/^https?:\/\//, "").replace(/\/$/, ""); 38 | }, [url]); 39 | 40 | React.useEffect(() => { 41 | if (opacityInput.current) opacityInput.current.value = `${storeOpacity}`; 42 | }, []); 43 | 44 | React.useEffect(() => { 45 | if (windowActive || childActive) { 46 | ipc.callMain("please-attach"); 47 | setDetach(false); 48 | } 49 | }, [windowActive, childActive]); 50 | 51 | const updateURL = (nextURL: string) => { 52 | setFocusVideo(false); 53 | ipc.callMain("please-disable-video-css"); 54 | setURL(nextURL); 55 | }; 56 | 57 | const handleUrlInputPaste: ClipboardEventHandler = (e) => { 58 | updateURL(urlInput.current?.value ?? ""); 59 | }; 60 | 61 | const handleUrlInputKeypress: KeyboardEventHandler = debounce((e) => { 62 | switch (e.keyCode) { 63 | case 8: // Backspace 64 | case 13: // Return 65 | case 17: // Ctrl 66 | case 91: // Cmd left 67 | case 93: // Cmd right 68 | updateURL(urlInput.current?.value ?? ""); 69 | break; 70 | default: 71 | // https://stackoverflow.com/a/1547940 72 | const newUrlKeys = new RegExp("^[a-zA-Z0-9-._~:/?#\\[\\]@!$&'()*+,;=]$"); 73 | const key = e.key; 74 | 75 | if (newUrlKeys.test(key)) { 76 | updateURL(urlInput.current?.value ?? ""); 77 | } 78 | break; 79 | } 80 | }, 750); 81 | 82 | const handleVideoCssChange: ChangeEventHandler = (e) => { 83 | if (e.currentTarget.checked) { 84 | setFocusVideo(true); 85 | ipc.callMain("please-enable-video-css"); 86 | } else { 87 | setFocusVideo(false); 88 | ipc.callMain("please-disable-video-css"); 89 | } 90 | }; 91 | 92 | const handleDetachModeChange: (close: () => void) => ChangeEventHandler = ( 93 | close 94 | ) => { 95 | return (e) => { 96 | if (e.currentTarget.checked) { 97 | ipc.callMain("please-detach"); 98 | window.dispatchEvent(new Event("blur")); 99 | close(); 100 | } 101 | }; 102 | }; 103 | 104 | const handleOpacityChange: ChangeEventHandler = debounce((e) => { 105 | setStoreOpacity(e.target.value ?? 100); 106 | }, 125); 107 | 108 | return ( 109 | 110 | 117 |
118 |
119 | 125 |
126 | 147 |
148 | 149 | 150 | {({ close }) => ( 151 | 152 | 159 | Options 160 | 161 | 169 | 170 |
171 | Options 172 |
173 |
174 | 187 |

188 | {opacity} 189 | % 190 |

191 |
192 |
193 | 199 |

200 | Use the slider to set the opacity of the flōt window 201 |

202 |
203 |
204 | 205 |
206 |
207 | 216 | 222 |
223 |
224 |

225 | Add (experimental) styles to the flōted page which make videos as large as 226 | possible, hiding other content. Does not work on all websites, and resets 227 | to "off" when you change websites. 228 |

229 |
230 |
231 | 232 |
233 |
234 | 243 | 252 |
253 |
254 |

255 | Ignore all mouse clicks on the Flōt app 256 | window. Restore the ability to click by using your keyboard to re-activate 257 | the Flōt app window. 258 |

259 |
260 |
261 |
262 |
263 |
264 |
265 | )} 266 |
267 |
268 |
269 | ); 270 | } 271 | 272 | export default FlotControls; 273 | -------------------------------------------------------------------------------- /renderer/components/FlotEmbed.tsx: -------------------------------------------------------------------------------- 1 | import cn from "classnames"; 2 | import React from "react"; 3 | import { useStore } from "../store"; 4 | import FlotPlaceholder from "./FlotPlaceholder"; 5 | 6 | function FlotEmbed() { 7 | const [firstLoad, setFirstLoad] = React.useState(false); 8 | const url = useStore((state) => state.url); 9 | const childLoading = useStore((state) => state.childLoading); 10 | const setChildLoading = useStore((state) => state.setChildLoading); 11 | 12 | React.useEffect(() => setFirstLoad(true), []); 13 | 14 | React.useEffect(() => { 15 | if (url) setChildLoading(true); 16 | }, [url]); 17 | 18 | const handlOnLoad = () => { 19 | setChildLoading(false); 20 | }; 21 | 22 | return ( 23 | 24 | {firstLoad && url ? ( 25 | 26 | 33 |
38 |
39 |
40 |

41 | Something went wrong :( 42 |

43 |

44 | Double check the address you entered to ensure it's a valid url. Some websites 45 | can't be displayed because their settings forbid it. 46 |

47 |
48 |
49 |
50 |
51 | ) : ( 52 | 53 | )} 54 |
55 | ); 56 | } 57 | 58 | export default FlotEmbed; 59 | -------------------------------------------------------------------------------- /renderer/components/FlotPlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import cn from "classnames"; 2 | import React from "react"; 3 | import { useStore } from "../store"; 4 | 5 | function FlotPlaceholder() { 6 | return ( 7 | 8 | 28 | 29 | ); 30 | } 31 | 32 | export default FlotPlaceholder; 33 | -------------------------------------------------------------------------------- /renderer/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /renderer/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | output: "export", 3 | distDir: process.env.NODE_ENV === "production" ? "../app" : ".next", 4 | trailingSlash: true, 5 | images: { 6 | unoptimized: true, 7 | }, 8 | webpack: (config, { isServer }) => { 9 | if (!isServer) { 10 | config.target = "electron-renderer"; 11 | config.node = { 12 | __dirname: true, 13 | }; 14 | } 15 | 16 | return config; 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /renderer/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import React from "react"; 3 | import { useStore } from "../store"; 4 | import "../styles/globals.css"; 5 | 6 | function MyApp({ Component, pageProps }: AppProps) { 7 | React.useEffect(() => { 8 | const onFocus = () => useStore.setState({ windowActive: true }); 9 | const onBlur = () => useStore.setState({ windowActive: false }); 10 | const onMouseenter = () => useStore.setState({ windowHover: true }); 11 | const onMouseleave = () => useStore.setState({ windowHover: false }); 12 | const onMsg = (event: MessageEvent) => { 13 | try { 14 | const { key = "UNKNOWN", msg = "UNKNOWN" } = event.data; 15 | 16 | switch (key) { 17 | case "location": 18 | if (typeof window !== "undefined") localStorage.setItem("flot-last-url", msg); 19 | useStore.setState({ url: msg }); 20 | break; 21 | case "active": 22 | useStore.setState({ childActive: msg }); 23 | break; 24 | case "hover": 25 | useStore.setState({ childHover: msg }); 26 | break; 27 | default: 28 | console.log(`unknown iframe message with key [${key}] and msg [${msg}]`); 29 | break; 30 | } 31 | } catch (error) { 32 | console.error(error); 33 | } 34 | }; 35 | 36 | window.addEventListener("focus", onFocus); 37 | window.addEventListener("blur", onBlur); 38 | window.addEventListener("message", onMsg); 39 | 40 | document.addEventListener("mouseenter", onMouseenter); 41 | document.addEventListener("mouseleave", onMouseleave); 42 | 43 | return function removeListeners() { 44 | window.removeEventListener("focus", onFocus); 45 | window.removeEventListener("blur", onBlur); 46 | window.removeEventListener("message", onMsg); 47 | 48 | document.removeEventListener("mouseenter", onMouseenter); 49 | document.removeEventListener("mouseleave", onMouseleave); 50 | }; 51 | }, []); 52 | 53 | return ( 54 | 55 | 56 | 57 | ); 58 | } 59 | 60 | export default MyApp; 61 | -------------------------------------------------------------------------------- /renderer/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { DocumentContext, Head, Html, Main, NextScript } from "next/document"; 2 | 3 | class FlotDocument extends Document { 4 | static async getInitialProps(ctx: DocumentContext) { 5 | const initialProps = await Document.getInitialProps(ctx); 6 | return { ...initialProps }; 7 | } 8 | 9 | render() { 10 | return ( 11 | 12 | 189 | 190 | 191 | -------------------------------------------------------------------------------- /website/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const baseConfig = require("../renderer/tailwind.config"); 2 | const path = require("node:path"); 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | module.exports = { 6 | presets: [baseConfig], 7 | content: [path.join(__dirname, "index.html")], 8 | theme: { 9 | extend: { 10 | animation: { 11 | flot: "flot 60s linear infinite", 12 | }, 13 | keyframes: { 14 | flot: { 15 | "0%": { transform: "translate3d(-100%, -5%, 0) scale(1)" }, 16 | "50%": { 17 | transform: "translate3d(50vw, 10%, 0) translateX(-50%) scale(1.1)", 18 | }, 19 | "100%": { transform: "translate3d(100vw, 2%, 0) scale(1)" }, 20 | }, 21 | }, 22 | }, 23 | }, 24 | }; 25 | --------------------------------------------------------------------------------