devlinker 1.3.7__tar.gz → 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {devlinker-1.3.7 → devlinker-1.4.0}/PKG-INFO +14 -1
  2. devlinker-1.3.7/devlinker.egg-info/PKG-INFO → devlinker-1.4.0/README.md +12 -18
  3. devlinker-1.4.0/devlinker/devlinker_loader_instant.html +126 -0
  4. devlinker-1.4.0/devlinker/devlinker_loader_snippet.html +209 -0
  5. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/main.py +101 -6
  6. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/proxy.py +116 -20
  7. devlinker-1.3.7/README.md → devlinker-1.4.0/devlinker.egg-info/PKG-INFO +31 -0
  8. devlinker-1.4.0/devlinker.egg-info/entry_points.txt +2 -0
  9. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker.egg-info/requires.txt +1 -0
  10. {devlinker-1.3.7 → devlinker-1.4.0}/pyproject.toml +3 -2
  11. devlinker-1.3.7/devlinker/devlinker_loader_instant.html +0 -100
  12. devlinker-1.3.7/devlinker/devlinker_loader_snippet.html +0 -128
  13. devlinker-1.3.7/devlinker.egg-info/entry_points.txt +0 -2
  14. {devlinker-1.3.7 → devlinker-1.4.0}/LICENSE +0 -0
  15. {devlinker-1.3.7 → devlinker-1.4.0}/MANIFEST.in +0 -0
  16. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/__init__.py +0 -0
  17. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/config.py +0 -0
  18. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/detection_state.py +0 -0
  19. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/detector.py +0 -0
  20. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/detector_ai.py +0 -0
  21. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/doctor.py +0 -0
  22. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/fix.py +0 -0
  23. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/fixer.py +0 -0
  24. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/global_state.py +0 -0
  25. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/inspect.py +0 -0
  26. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/logger.py +0 -0
  27. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/monitor.py +0 -0
  28. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/runner.py +0 -0
  29. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/share.py +0 -0
  30. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker/tunnel.py +0 -0
  31. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker.egg-info/SOURCES.txt +0 -0
  32. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker.egg-info/dependency_links.txt +0 -0
  33. {devlinker-1.3.7 → devlinker-1.4.0}/devlinker.egg-info/top_level.txt +0 -0
  34. {devlinker-1.3.7 → devlinker-1.4.0}/setup.cfg +0 -0
  35. {devlinker-1.3.7 → devlinker-1.4.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devlinker
3
- Version: 1.3.7
3
+ Version: 1.4.0
4
4
  Summary: A lightweight proxy that combines your frontend and backend into one link for easy development and sharing.
5
5
  Author-email: Mani <mani1028@users.noreply.github.com>
6
6
  Requires-Python: >=3.7
@@ -11,6 +11,7 @@ Requires-Dist: docker
11
11
  Requires-Dist: fastapi
12
12
  Requires-Dist: httpx
13
13
  Requires-Dist: pyngrok
14
+ Requires-Dist: qrcode[pil]
14
15
  Requires-Dist: requests
15
16
  Requires-Dist: uvicorn
16
17
  Requires-Dist: websockets
@@ -35,9 +36,21 @@ Dev Linker runs frontend and backend dev servers, proxies both through a single
35
36
  - 🧪 **Runtime Smoke Test:** Built-in test for end-to-end proxy validation.
36
37
  - 🛠️ **Extensible:** Modular architecture for future SaaS, dashboard, and team features.
37
38
 
39
+ ## Support DevLinker
40
+
41
+ If DevLinker helps you ship faster, consider supporting the project:
42
+
43
+ > 💖 Support DevLinker
44
+ > [![UPI](https://img.shields.io/badge/UPI-devlinker%40upi-22c55e?style=for-the-badge)](upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀)
45
+ > [![Donate](https://img.shields.io/badge/Support-Scan%20or%20Pay-0ea5e9?style=for-the-badge)](upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀)
46
+
47
+ - 💖 UPI: devlinker@upi
48
+ - 🔗 UPI link: upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀
49
+
38
50
  ## CLI Commands & Options
39
51
 
40
52
  - `devlinker` — Start proxy (local only, fast)
53
+ - `devlinker support` — Show UPI support QR code in terminal
41
54
  - `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
42
55
  - `devlinker share` — Enable public tunnel at runtime (no restart)
43
56
  - `devlinker unshare` — Disable public tunnel at runtime
@@ -1,21 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: devlinker
3
- Version: 1.3.7
4
- Summary: A lightweight proxy that combines your frontend and backend into one link for easy development and sharing.
5
- Author-email: Mani <mani1028@users.noreply.github.com>
6
- Requires-Python: >=3.7
7
- Description-Content-Type: text/markdown
8
- License-File: LICENSE
9
- Requires-Dist: click
10
- Requires-Dist: docker
11
- Requires-Dist: fastapi
12
- Requires-Dist: httpx
13
- Requires-Dist: pyngrok
14
- Requires-Dist: requests
15
- Requires-Dist: uvicorn
16
- Requires-Dist: websockets
17
- Dynamic: license-file
18
-
19
1
  # Dev Linker
20
2
 
21
3
  Dev Linker runs frontend and backend dev servers, proxies both through a single local port (8000), and creates a single public URL via Cloudflare or ngrok.
@@ -35,9 +17,21 @@ Dev Linker runs frontend and backend dev servers, proxies both through a single
35
17
  - 🧪 **Runtime Smoke Test:** Built-in test for end-to-end proxy validation.
36
18
  - 🛠️ **Extensible:** Modular architecture for future SaaS, dashboard, and team features.
37
19
 
20
+ ## Support DevLinker
21
+
22
+ If DevLinker helps you ship faster, consider supporting the project:
23
+
24
+ > 💖 Support DevLinker
25
+ > [![UPI](https://img.shields.io/badge/UPI-devlinker%40upi-22c55e?style=for-the-badge)](upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀)
26
+ > [![Donate](https://img.shields.io/badge/Support-Scan%20or%20Pay-0ea5e9?style=for-the-badge)](upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀)
27
+
28
+ - 💖 UPI: devlinker@upi
29
+ - 🔗 UPI link: upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀
30
+
38
31
  ## CLI Commands & Options
39
32
 
40
33
  - `devlinker` — Start proxy (local only, fast)
34
+ - `devlinker support` — Show UPI support QR code in terminal
41
35
  - `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
42
36
  - `devlinker share` — Enable public tunnel at runtime (no restart)
43
37
  - `devlinker unshare` — Disable public tunnel at runtime
@@ -0,0 +1,126 @@
1
+ <!-- DevLinker Instant Loader for Local/WLAN Users -->
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>DevLinker Loading</title>
8
+ <style>
9
+ * { box-sizing: border-box; }
10
+ body {
11
+ margin: 0;
12
+ min-height: 100vh;
13
+ background: #0b1220;
14
+ font-family: "Segoe UI", Arial, sans-serif;
15
+ color: #e2e8f0;
16
+ }
17
+ #devlinker-loader-min {
18
+ position: fixed;
19
+ inset: 0;
20
+ z-index: 99999;
21
+ display: grid;
22
+ place-items: center;
23
+ padding: 24px;
24
+ }
25
+ .devlinker-center {
26
+ display: flex;
27
+ flex-direction: column;
28
+ align-items: center;
29
+ gap: 10px;
30
+ }
31
+ .devlinker-logo {
32
+ font-size: 1.15rem;
33
+ font-weight: 700;
34
+ letter-spacing: 0.01em;
35
+ margin-bottom: 4px;
36
+ color: #f8fafc;
37
+ }
38
+ .devlinker-spinner {
39
+ width: 40px;
40
+ height: 40px;
41
+ border: 4px solid rgba(148, 163, 184, 0.25);
42
+ border-top: 4px solid #22d3ee;
43
+ border-radius: 50%;
44
+ animation: devlinker-spin 1s linear infinite;
45
+ }
46
+ .devlinker-text {
47
+ font-size: 1rem;
48
+ color: #cbd5e1;
49
+ font-weight: 600;
50
+ }
51
+ .devlinker-health {
52
+ font-size: 0.84rem;
53
+ color: #94a3b8;
54
+ opacity: 0.9;
55
+ }
56
+ .devlinker-powered {
57
+ position: fixed;
58
+ bottom: 18px;
59
+ left: 0;
60
+ width: 100vw;
61
+ text-align: center;
62
+ color: #94a3b8;
63
+ font-size: 0.92rem;
64
+ font-weight: 500;
65
+ opacity: 0.9;
66
+ pointer-events: auto;
67
+ user-select: text;
68
+ }
69
+ .devlinker-powered span {
70
+ display: block;
71
+ margin-top: 4px;
72
+ color: #cbd5e1;
73
+ }
74
+ .devlinker-powered a {
75
+ color: #7dd3fc;
76
+ text-decoration: none;
77
+ font-weight: 600;
78
+ }
79
+ .devlinker-powered a:hover {
80
+ color: #bae6fd;
81
+ text-decoration: underline;
82
+ }
83
+ @keyframes devlinker-spin {
84
+ to { transform: rotate(360deg); }
85
+ }
86
+ </style>
87
+ </head>
88
+ <body>
89
+ <div id="devlinker-loader-min">
90
+ <div class="devlinker-center">
91
+ <div class="devlinker-logo">DevLinker</div>
92
+ <div class="devlinker-spinner"></div>
93
+ <div class="devlinker-text">Loading your app...</div>
94
+ <div class="devlinker-health">Frontend connected • Backend connected</div>
95
+ </div>
96
+ </div>
97
+ <div class="devlinker-powered">
98
+ Powered by DevLinker 🚀
99
+ <span>Support the project ❤️ <a href="upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀">devlinker@upi</a></span>
100
+ </div>
101
+ <script>
102
+ // Fetch the real page and replace the loader with a short minimum display for smoothness.
103
+ (async function() {
104
+ const minTime = 500;
105
+ const start = Date.now();
106
+ try {
107
+ const resp = await fetch(window.location.href, { headers: { "X-DevLinker-Instant": "1" } });
108
+ const html = await resp.text();
109
+ const elapsed = Date.now() - start;
110
+ const delay = Math.max(0, minTime - elapsed);
111
+ setTimeout(() => {
112
+ document.open();
113
+ document.write(html);
114
+ document.close();
115
+ }, delay);
116
+ } catch (e) {
117
+ // Show error if fetch fails
118
+ var loader = document.getElementById("devlinker-loader-min");
119
+ if (loader) {
120
+ loader.innerHTML += '<div style="color:#b91c1c;font-size:1.05rem;margin-top:1.5rem;">Failed to load app. Please check your connection.</div>';
121
+ }
122
+ }
123
+ })();
124
+ </script>
125
+ </body>
126
+ </html>
@@ -0,0 +1,209 @@
1
+ <!-- DevLinker Loader Overlay -->
2
+ <style>
3
+ #devlinker-loader {
4
+ position: fixed;
5
+ z-index: 99999;
6
+ inset: 0;
7
+ display: grid;
8
+ place-items: center;
9
+ min-height: 100vh;
10
+ background:
11
+ radial-gradient(700px 360px at 18% 12%, rgba(34, 211, 238, 0.18), transparent 60%),
12
+ radial-gradient(520px 280px at 82% 18%, rgba(59, 130, 246, 0.16), transparent 58%),
13
+ #0f172a;
14
+ font-family: "Segoe UI", Arial, sans-serif;
15
+ transition: opacity 0.4s cubic-bezier(.4,0,.2,1);
16
+ color: #e2e8f0;
17
+ }
18
+ .devlinker-panel {
19
+ width: min(640px, 92vw);
20
+ border: 1px solid rgba(148, 163, 184, 0.25);
21
+ border-radius: 18px;
22
+ background: rgba(15, 23, 42, 0.75);
23
+ box-shadow: 0 24px 70px rgba(2, 6, 23, 0.55);
24
+ backdrop-filter: blur(8px);
25
+ padding: 24px 22px;
26
+ }
27
+ .devlinker-top {
28
+ font-weight: 700;
29
+ font-size: 1.2rem;
30
+ color: #f8fafc;
31
+ margin-bottom: 12px;
32
+ }
33
+ .devlinker-title {
34
+ font-size: 1.15rem;
35
+ font-weight: 650;
36
+ margin-bottom: 8px;
37
+ color: #f8fafc;
38
+ }
39
+ .devlinker-stage {
40
+ min-height: 1.45rem;
41
+ font-size: 0.98rem;
42
+ color: #a5b4fc;
43
+ margin-bottom: 14px;
44
+ }
45
+ .devlinker-center {
46
+ display: flex;
47
+ flex-direction: column;
48
+ align-items: center;
49
+ gap: 10px;
50
+ padding: 14px 0 16px;
51
+ }
52
+ .devlinker-spinner {
53
+ width: 52px;
54
+ height: 52px;
55
+ border: 4px solid rgba(148, 163, 184, 0.25);
56
+ border-top: 4px solid #22d3ee;
57
+ border-right: 4px solid #60a5fa;
58
+ border-radius: 50%;
59
+ animation: devlinker-spin 1s linear infinite;
60
+ }
61
+ .devlinker-text {
62
+ font-size: 1rem;
63
+ color: #cbd5e1;
64
+ font-weight: 600;
65
+ }
66
+ .devlinker-divider {
67
+ height: 1px;
68
+ width: 100%;
69
+ background: rgba(148, 163, 184, 0.22);
70
+ margin: 8px 0 14px;
71
+ }
72
+ .devlinker-info {
73
+ display: grid;
74
+ grid-template-columns: 1fr;
75
+ gap: 10px;
76
+ color: #cbd5e1;
77
+ font-size: 0.92rem;
78
+ }
79
+ .devlinker-tip {
80
+ color: #cbd5e1;
81
+ line-height: 1.4;
82
+ }
83
+ .devlinker-upgrade {
84
+ color: #93c5fd;
85
+ text-decoration: none;
86
+ font-weight: 600;
87
+ display: inline-block;
88
+ margin-top: 2px;
89
+ }
90
+ .devlinker-upgrade:hover {
91
+ color: #bfdbfe;
92
+ }
93
+ .devlinker-powered {
94
+ margin-top: 14px;
95
+ text-align: center;
96
+ color: #94a3b8;
97
+ font-size: 0.9rem;
98
+ font-weight: 500;
99
+ opacity: 0.95;
100
+ }
101
+ .devlinker-support {
102
+ color: #cbd5e1;
103
+ line-height: 1.45;
104
+ }
105
+ .devlinker-support a {
106
+ color: #7dd3fc;
107
+ text-decoration: none;
108
+ font-weight: 600;
109
+ }
110
+ .devlinker-support a:hover {
111
+ color: #bae6fd;
112
+ text-decoration: underline;
113
+ }
114
+ .devlinker-warning {
115
+ display: none;
116
+ margin-top: 12px;
117
+ border: 1px solid rgba(251, 191, 36, 0.45);
118
+ background: rgba(120, 53, 15, 0.26);
119
+ border-radius: 12px;
120
+ padding: 10px 12px;
121
+ color: #fde68a;
122
+ font-size: 0.9rem;
123
+ line-height: 1.4;
124
+ }
125
+ .devlinker-warning strong {
126
+ color: #fef3c7;
127
+ }
128
+ @keyframes devlinker-spin {
129
+ to { transform: rotate(360deg); }
130
+ }
131
+ @media (max-width: 640px) {
132
+ .devlinker-panel {
133
+ padding: 18px 16px;
134
+ }
135
+ .devlinker-title {
136
+ font-size: 1.04rem;
137
+ }
138
+ }
139
+ </style>
140
+ <div id="devlinker-loader">
141
+ <div class="devlinker-panel">
142
+ <div class="devlinker-top">DevLinker</div>
143
+ <div class="devlinker-title">Connecting your app...</div>
144
+ <div class="devlinker-stage" id="devlinker-stage">Establishing secure tunnel...</div>
145
+ <div class="devlinker-center">
146
+ <div class="devlinker-spinner"></div>
147
+ <div class="devlinker-text">Preparing full experience</div>
148
+ </div>
149
+ <div class="devlinker-divider"></div>
150
+ <div class="devlinker-info">
151
+ <div class="devlinker-tip">Tip: Share your app instantly with one link. No CORS issues, no port confusion.</div>
152
+ <div class="devlinker-tip">Go Pro: custom domains, faster sharing, and no branding.</div>
153
+ <a class="devlinker-upgrade" href="https://devlinker.app" target="_blank" rel="noopener">Explore Pro at devlinker.app</a>
154
+ <div class="devlinker-support">Support the project ❤️ <a href="upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀">devlinker@upi</a></div>
155
+ <div class="devlinker-warning" id="devlinker-camera-warning">
156
+ <strong>Camera warning:</strong> camera and microphone are blocked on LAN HTTP URLs by browser policy. Use localhost or run <strong>devlinker --url</strong> for HTTPS sharing.
157
+ </div>
158
+ </div>
159
+ <div class="devlinker-powered">Powered by DevLinker 🚀</div>
160
+ </div>
161
+ </div>
162
+ <script>
163
+ const stageMessages = [
164
+ "Establishing secure tunnel...",
165
+ "Routing frontend assets...",
166
+ "Connecting backend services...",
167
+ "Optimizing connection...",
168
+ "Almost ready..."
169
+ ];
170
+ let stageIndex = 0;
171
+ const stageElement = document.getElementById("devlinker-stage");
172
+ const stageTimer = setInterval(() => {
173
+ if (!stageElement) return;
174
+ stageIndex = (stageIndex + 1) % stageMessages.length;
175
+ stageElement.textContent = stageMessages[stageIndex];
176
+ }, 1000);
177
+
178
+ const start = Date.now();
179
+ const MIN_TIME = 800;
180
+ const MAX_TIME = 5000;
181
+ let hidden = false;
182
+
183
+ const cameraContext = window.__DEVLINKER_CAMERA_CONTEXT__ || {};
184
+ const warningElement = document.getElementById("devlinker-camera-warning");
185
+ if (warningElement) {
186
+ const isLan = cameraContext.mode === "lan";
187
+ const isSecure = Boolean(cameraContext.secure);
188
+ if (isLan && !isSecure) {
189
+ warningElement.style.display = "block";
190
+ }
191
+ }
192
+
193
+ function hideLoader() {
194
+ if (hidden) return;
195
+ hidden = true;
196
+ clearInterval(stageTimer);
197
+ const elapsed = Date.now() - start;
198
+ const delay = Math.max(0, MIN_TIME - elapsed);
199
+ const el = document.getElementById("devlinker-loader");
200
+ setTimeout(() => {
201
+ if (el) {
202
+ el.style.opacity = "0";
203
+ setTimeout(() => el.remove(), 400);
204
+ }
205
+ }, delay);
206
+ }
207
+ window.addEventListener("load", hideLoader);
208
+ setTimeout(hideLoader, MAX_TIME);
209
+ </script>
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import socket
4
4
  import time
5
+ import webbrowser
5
6
  from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
6
7
 
7
8
  import click
@@ -19,6 +20,32 @@ from .config import load_config
19
20
  from .inspect import inspect
20
21
  from .monitor import monitor
21
22
 
23
+ SUPPORT_UPI_ID = "devlinker@upi"
24
+ SUPPORT_UPI_LINK = "upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀"
25
+ SUPPORT_QR_FALLBACK = [
26
+ "#######.......#######",
27
+ "#.....#.......#.....#",
28
+ "#.###.#.......#.###.#",
29
+ "#.###.#.......#.###.#",
30
+ "#.###.#.......#.###.#",
31
+ "#.....#.......#.....#",
32
+ "#######.......#######",
33
+ "##..##.##..##.##..##.",
34
+ ".##..##..##..##..##..",
35
+ "###...###...###...###",
36
+ "..###....###....###..",
37
+ "###..#.###..#.###..#.",
38
+ "#..###.#..###.#..###.",
39
+ "....#......#......#..",
40
+ "#######..............",
41
+ "#.....#..............",
42
+ "#.###.#..............",
43
+ "#.###.#..............",
44
+ "#.###.#..............",
45
+ "#.....#..............",
46
+ "#######..............",
47
+ ]
48
+
22
49
 
23
50
  def _is_port_in_use(port: int) -> bool:
24
51
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
@@ -83,6 +110,35 @@ def _print_summary(
83
110
  click.secho("Tip: Press Ctrl+Click to open link", fg="magenta")
84
111
  else:
85
112
  click.secho(" Public → Disabled (use --url)", fg="yellow")
113
+ click.secho("\n💡 Enjoying DevLinker? Support the project ❤️", fg="magenta")
114
+ click.secho(f"UPI: {SUPPORT_UPI_ID}", fg="yellow")
115
+ click.secho("Run: devlinker support (shows QR)", fg="yellow")
116
+
117
+
118
+ def _print_support_qr(open_link: bool) -> None:
119
+ click.secho("\n💖 Support DevLinker 🚀", fg="magenta", bold=True)
120
+ click.secho("Help keep the tool free and improving!", fg="white")
121
+ click.secho(f"\nUPI: {SUPPORT_UPI_ID}", fg="yellow")
122
+ click.secho(f"Link: {SUPPORT_UPI_LINK}\n", fg="cyan")
123
+
124
+ try:
125
+ import qrcode
126
+ except ImportError:
127
+ click.secho("ASCII QR (fallback):", fg="yellow")
128
+ for row in SUPPORT_QR_FALLBACK:
129
+ click.echo(row.replace("#", "##").replace(".", " "))
130
+ click.secho("\nInstall full QR support: pip install qrcode[pil]", fg="yellow")
131
+ if open_link:
132
+ webbrowser.open(SUPPORT_UPI_LINK)
133
+ return
134
+
135
+ qr = qrcode.QRCode(border=1)
136
+ qr.add_data(SUPPORT_UPI_LINK)
137
+ qr.make(fit=True)
138
+ qr.print_ascii(invert=True)
139
+
140
+ if open_link:
141
+ webbrowser.open(SUPPORT_UPI_LINK)
86
142
 
87
143
 
88
144
  def _get_local_ip() -> str | None:
@@ -117,7 +173,7 @@ def _wait_for_readiness(
117
173
  return False
118
174
 
119
175
 
120
- @click.command()
176
+ @click.group(invoke_without_command=True)
121
177
  @click.version_option(version=__version__, prog_name="devlinker")
122
178
  @click.option("--frontend", type=int, default=None, help="Override detected frontend port.")
123
179
  @click.option(
@@ -151,8 +207,36 @@ def _wait_for_readiness(
151
207
  help="Show WLAN sharing URL for devices on the same network.",
152
208
  )
153
209
  @click.option("--debug", is_flag=True, hidden=True, help="Enable debug logging.")
210
+ @click.pass_context
211
+ def main(
212
+ ctx: click.Context,
213
+ frontend: int | None,
214
+ backend_port_override: int | None,
215
+ proxy_port: int,
216
+ auto_start_docker: bool,
217
+ url: bool,
218
+ no_tunnel: bool,
219
+ interactive_backend: bool,
220
+ lan_enabled: bool,
221
+ debug: bool,
222
+ ) -> None:
223
+ if ctx.invoked_subcommand is not None:
224
+ return
154
225
 
155
- def cli(
226
+ _run_proxy(
227
+ frontend,
228
+ backend_port_override,
229
+ proxy_port,
230
+ auto_start_docker,
231
+ url,
232
+ no_tunnel,
233
+ interactive_backend,
234
+ lan_enabled,
235
+ debug,
236
+ )
237
+
238
+
239
+ def _run_proxy(
156
240
  frontend: int | None,
157
241
  backend_port_override: int | None,
158
242
  proxy_port: int,
@@ -236,6 +320,11 @@ def cli(
236
320
  wlan_url = f"http://{local_ip}:{proxy_port}"
237
321
  click.secho(f"[OK] WLAN URL: {wlan_url}", fg="green")
238
322
  click.secho("[INFO] Share WLAN link with teammates on same WiFi/LAN.", fg="blue")
323
+ click.secho(
324
+ "[WARN] Camera/mic may be blocked on WLAN HTTP links by browser security."
325
+ " Use localhost or --url for HTTPS.",
326
+ fg="yellow",
327
+ )
239
328
  else:
240
329
  click.secho("[WARN] WLAN URL unavailable (no active LAN interface detected).", fg="yellow")
241
330
  click.secho("[INFO] If LAN sharing fails, allow proxy port in firewall and use same network.", fg="yellow")
@@ -286,19 +375,25 @@ def cli(
286
375
  click.secho("\n[INFO] Dev Linker stopped.", fg="yellow")
287
376
 
288
377
 
289
- @click.group()
290
- def main():
291
- pass
378
+ @click.command()
379
+ @click.option(
380
+ "--open",
381
+ "open_link",
382
+ is_flag=True,
383
+ help="Open the UPI link in your browser after rendering the QR.",
384
+ )
385
+ def support(open_link: bool) -> None:
386
+ _print_support_qr(open_link)
292
387
 
293
388
 
294
389
 
295
- main.add_command(cli)
296
390
  main.add_command(doctor)
297
391
  main.add_command(fix)
298
392
  main.add_command(share)
299
393
  main.add_command(unshare)
300
394
  main.add_command(inspect)
301
395
  main.add_command(monitor)
396
+ main.add_command(support)
302
397
 
303
398
  if __name__ == "__main__":
304
399
  main()
@@ -21,6 +21,21 @@ from devlinker.detection_state import state
21
21
  import threading
22
22
  _recent_requests = []
23
23
  _recent_lock = threading.Lock()
24
+ _printed_fixes = set()
25
+
26
+
27
+ def _format_request_context(path: str, method: str | None, status: int, target: str) -> str:
28
+ safe_method = method.upper() if method else "UNKNOWN"
29
+ return f"{safe_method} {path} -> {target} ({status})"
30
+
31
+
32
+ def _print_fix_once(message: str) -> None:
33
+ key = message.strip().lower()
34
+ if key in _printed_fixes:
35
+ return
36
+ _printed_fixes.add(key)
37
+ from devlinker.logger import print_fix
38
+ print_fix(message)
24
39
 
25
40
  class RequestInspector:
26
41
  def analyze(self, path, status, target, method=None, response_text=None):
@@ -73,6 +88,12 @@ HOP_BY_HOP_HEADERS = {
73
88
  }
74
89
 
75
90
 
91
+ def _apply_security_headers(headers: Dict[str, str]) -> Dict[str, str]:
92
+ # Explicitly allow camera/mic for the current origin.
93
+ headers["Permissions-Policy"] = "camera=(self), microphone=(self)"
94
+ return headers
95
+
96
+
76
97
  @app.on_event("startup")
77
98
  async def _on_startup() -> None:
78
99
  global HTTP_CLIENT
@@ -142,12 +163,17 @@ def _build_target_ws_url(port: int, path: str, query: str) -> str:
142
163
 
143
164
 
144
165
  async def _forward_http(request: Request) -> Response:
145
- # Serve instant loader for local/LAN users unless X-DevLinker-Instant header is present
166
+ # Serve instant loader only for localhost HTML document navigations.
146
167
  client_ip = request.client.host if request.client else None
147
- def is_local_network(ip):
168
+ host_header = request.headers.get("host", "").lower()
169
+
170
+ def is_localhost_ip(ip):
148
171
  if not ip:
149
172
  return False
150
- if ip.startswith("127.") or ip == "localhost" or ip == "::1":
173
+ return ip.startswith("127.") or ip == "localhost" or ip == "::1"
174
+
175
+ def is_lan_ip(ip):
176
+ if not ip:
151
177
  return False
152
178
  if ip.startswith("192.168.") or ip.startswith("10."):
153
179
  return True
@@ -158,15 +184,67 @@ async def _forward_http(request: Request) -> Response:
158
184
  except Exception:
159
185
  return False
160
186
  return False
161
- is_local = is_local_network(client_ip)
187
+
188
+ def classify_mode(host: str, ip: str | None) -> str:
189
+ host_only = host.split(":", 1)[0] if host else ""
190
+ if host_only in ("localhost", "127.0.0.1", "::1"):
191
+ return "localhost"
192
+ if host_only.startswith("192.168.") or host_only.startswith("10."):
193
+ return "lan"
194
+ if host_only.startswith("172."):
195
+ try:
196
+ second = int(host_only.split(".")[1])
197
+ if 16 <= second <= 31:
198
+ return "lan"
199
+ except Exception:
200
+ pass
201
+ if host_only and host_only not in ("", "0.0.0.0"):
202
+ return "public"
203
+ if is_localhost_ip(ip):
204
+ return "localhost"
205
+ if is_lan_ip(ip):
206
+ return "lan"
207
+ return "public" if ip else "unknown"
208
+
209
+ def _is_secure_request(req: Request, host: str) -> bool:
210
+ if req.url.scheme == "https":
211
+ return True
212
+ forwarded_proto = req.headers.get("x-forwarded-proto", "").lower()
213
+ if "https" in forwarded_proto:
214
+ return True
215
+ host_only = host.split(":", 1)[0] if host else ""
216
+ return host_only in ("localhost", "127.0.0.1", "::1")
217
+
218
+ mode = classify_mode(host_header, client_ip)
219
+ is_localhost = mode == "localhost"
220
+ is_lan = mode == "lan"
221
+ is_public = mode == "public"
222
+ is_secure = _is_secure_request(request, host_header)
162
223
  is_instant = request.headers.get("x-devlinker-instant") == "1"
163
- if is_local and not is_instant and request.method == "GET":
224
+ accept_header = request.headers.get("accept", "")
225
+ sec_fetch_dest = request.headers.get("sec-fetch-dest", "")
226
+ is_html_request = "text/html" in accept_header.lower()
227
+ is_document_navigation = sec_fetch_dest in ("", "document")
228
+ is_api_path = request.url.path == "/api" or request.url.path.startswith("/api/")
229
+ if (
230
+ is_localhost
231
+ and not is_instant
232
+ and request.method == "GET"
233
+ and is_html_request
234
+ and is_document_navigation
235
+ and not is_api_path
236
+ ):
164
237
  import os
165
238
  loader_path = os.path.join(os.path.dirname(__file__), "devlinker_loader_instant.html")
166
239
  with open(loader_path, encoding="utf-8") as f:
167
240
  loader_html = f.read()
168
- return Response(content=loader_html, status_code=200, media_type="text/html")
169
- from devlinker.logger import print_warning, print_fix
241
+ return Response(
242
+ content=loader_html,
243
+ status_code=200,
244
+ media_type="text/html",
245
+ headers=_apply_security_headers({}),
246
+ )
247
+ from devlinker.logger import print_warning
170
248
  from devlinker.detector_ai import DevLinkerAI
171
249
 
172
250
  inspector = RequestInspector()
@@ -177,16 +255,21 @@ async def _forward_http(request: Request) -> Response:
177
255
  if request.url.path.startswith("/api"):
178
256
  status = 503
179
257
  warnings = inspector.analyze(request.url.path, status, "backend", method=request.method)
258
+ context = _format_request_context(request.url.path, request.method, status, "backend")
180
259
  for w in warnings:
181
260
  if "/api" in w:
182
- print_warning(f"API routing issue detected\n👉 {request.url.path} returned 404\n👉 Try: /api{request.url.path}")
261
+ print_warning(
262
+ f"API routing issue detected | {context}\n"
263
+ f"Fix: Try /api{request.url.path}"
264
+ )
183
265
  else:
184
- print_warning(w)
266
+ print_warning(f"{w} | {context}")
185
267
  return PlainTextResponse("Backend is not configured.", status_code=status)
186
268
  status = 503
187
269
  warnings = inspector.analyze(request.url.path, status, "frontend", method=request.method)
270
+ context = _format_request_context(request.url.path, request.method, status, "frontend")
188
271
  for w in warnings:
189
- print_warning(w)
272
+ print_warning(f"{w} | {context}")
190
273
  return PlainTextResponse("Frontend is not configured.", status_code=status)
191
274
 
192
275
  if HTTP_CLIENT is None:
@@ -207,11 +290,12 @@ async def _forward_http(request: Request) -> Response:
207
290
  except httpx.RequestError as exc:
208
291
  status = 502
209
292
  warnings = inspector.analyze(request.url.path, status, "backend", method=request.method)
293
+ context = _format_request_context(request.url.path, request.method, status, "backend")
210
294
  for w in warnings:
211
- print_warning(w)
295
+ print_warning(f"{w} | {context}")
212
296
  ai_suggestions = ai.analyze_failure(str(exc))
213
297
  for s in ai_suggestions:
214
- print_fix(s)
298
+ _print_fix_once(f"{s} | {context}")
215
299
  return PlainTextResponse(f"Upstream unavailable: {exc}", status_code=status)
216
300
 
217
301
  # Analyze response for warnings and fixes
@@ -222,22 +306,26 @@ async def _forward_http(request: Request) -> Response:
222
306
  method=request.method,
223
307
  response_text=upstream.text
224
308
  )
309
+ context = _format_request_context(request.url.path, request.method, upstream.status_code, "backend")
225
310
  for w in warnings:
226
311
  if "/api" in w:
227
- print_warning(f"API routing issue detected\n👉 {request.url.path} returned 404\n👉 Try: /api{request.url.path}")
312
+ print_warning(
313
+ f"API routing issue detected | {context}\n"
314
+ f"Fix: Try /api{request.url.path}"
315
+ )
228
316
  else:
229
- print_warning(w)
317
+ print_warning(f"{w} | {context}")
230
318
  ai_suggestions = ai.analyze_failure(str(upstream.text))
231
319
  for s in ai_suggestions:
232
- print_fix(s)
320
+ _print_fix_once(f"{s} | {context}")
233
321
 
234
- # Only inject loader for HTML responses, not localhost/loopback, but DO inject for LAN/WiFi clients
235
- headers = _filter_response_headers(dict(upstream.headers))
322
+ # Inject loader overlay only for LAN/WiFi/public HTML responses.
323
+ headers = _apply_security_headers(_filter_response_headers(dict(upstream.headers)))
236
324
  content_type = headers.get("content-type", "")
237
325
  is_html = "text/html" in content_type
238
- is_public = client_ip and not is_local and (not client_ip.startswith("127.") and client_ip != "localhost" and client_ip != "::1")
239
326
  content = upstream.content
240
- if is_html and (is_local or is_public):
327
+ # Only inject loader if NOT an instant loader background fetch
328
+ if is_html and (is_lan or is_public) and not is_instant:
241
329
  try:
242
330
  html = content.decode(upstream.encoding or "utf-8", errors="replace")
243
331
  # Only inject if </body> exists
@@ -246,7 +334,15 @@ async def _forward_http(request: Request) -> Response:
246
334
  loader_file = "devlinker_loader_snippet.html"
247
335
  with open(os.path.join(os.path.dirname(__file__), loader_file), encoding="utf-8") as f:
248
336
  loader = f.read()
249
- html = html.replace("</body>", loader + "</body>")
337
+ context_script = (
338
+ "<script>window.__DEVLINKER_CAMERA_CONTEXT__="
339
+ + "{"
340
+ + f'"mode":"{mode}",'
341
+ + f'"secure":{str(is_secure).lower()}'
342
+ + "}"
343
+ + ";</script>"
344
+ )
345
+ html = html.replace("</body>", context_script + loader + "</body>")
250
346
  content = html.encode(upstream.encoding or "utf-8")
251
347
  except Exception:
252
348
  pass
@@ -1,3 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: devlinker
3
+ Version: 1.4.0
4
+ Summary: A lightweight proxy that combines your frontend and backend into one link for easy development and sharing.
5
+ Author-email: Mani <mani1028@users.noreply.github.com>
6
+ Requires-Python: >=3.7
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: click
10
+ Requires-Dist: docker
11
+ Requires-Dist: fastapi
12
+ Requires-Dist: httpx
13
+ Requires-Dist: pyngrok
14
+ Requires-Dist: qrcode[pil]
15
+ Requires-Dist: requests
16
+ Requires-Dist: uvicorn
17
+ Requires-Dist: websockets
18
+ Dynamic: license-file
19
+
1
20
  # Dev Linker
2
21
 
3
22
  Dev Linker runs frontend and backend dev servers, proxies both through a single local port (8000), and creates a single public URL via Cloudflare or ngrok.
@@ -17,9 +36,21 @@ Dev Linker runs frontend and backend dev servers, proxies both through a single
17
36
  - 🧪 **Runtime Smoke Test:** Built-in test for end-to-end proxy validation.
18
37
  - 🛠️ **Extensible:** Modular architecture for future SaaS, dashboard, and team features.
19
38
 
39
+ ## Support DevLinker
40
+
41
+ If DevLinker helps you ship faster, consider supporting the project:
42
+
43
+ > 💖 Support DevLinker
44
+ > [![UPI](https://img.shields.io/badge/UPI-devlinker%40upi-22c55e?style=for-the-badge)](upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀)
45
+ > [![Donate](https://img.shields.io/badge/Support-Scan%20or%20Pay-0ea5e9?style=for-the-badge)](upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀)
46
+
47
+ - 💖 UPI: devlinker@upi
48
+ - 🔗 UPI link: upi://pay?pa=devlinker@upi&pn=DevLinker&cu=INR&tn=Support%20DevLinker%20Project%20🚀
49
+
20
50
  ## CLI Commands & Options
21
51
 
22
52
  - `devlinker` — Start proxy (local only, fast)
53
+ - `devlinker support` — Show UPI support QR code in terminal
23
54
  - `devlinker --url` — Start with public tunnel (Cloudflare/ngrok)
24
55
  - `devlinker share` — Enable public tunnel at runtime (no restart)
25
56
  - `devlinker unshare` — Disable public tunnel at runtime
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ devlinker = devlinker.main:main
@@ -3,6 +3,7 @@ docker
3
3
  fastapi
4
4
  httpx
5
5
  pyngrok
6
+ qrcode[pil]
6
7
  requests
7
8
  uvicorn
8
9
  websockets
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devlinker"
7
- version = "1.3.7"
7
+ version = "1.4.0"
8
8
  description = "A lightweight proxy that combines your frontend and backend into one link for easy development and sharing."
9
9
  authors = [
10
10
  { name = "Mani", email = "mani1028@users.noreply.github.com" }
@@ -17,13 +17,14 @@ dependencies = [
17
17
  "fastapi",
18
18
  "httpx",
19
19
  "pyngrok",
20
+ "qrcode[pil]",
20
21
  "requests",
21
22
  "uvicorn",
22
23
  "websockets",
23
24
  ]
24
25
 
25
26
  [project.scripts]
26
- devlinker = "devlinker.main:cli"
27
+ devlinker = "devlinker.main:main"
27
28
 
28
29
  [tool.setuptools.packages.find]
29
30
  include = ["devlinker*"]
@@ -1,100 +0,0 @@
1
- <!-- DevLinker Instant Loader for Local/LAN Users -->
2
- <!DOCTYPE html>
3
- <html lang="en">
4
- <head>
5
- <meta charset="UTF-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
- <title>Loading...</title>
8
- <style>
9
- #devlinker-loader-min {
10
- position: fixed;
11
- inset: 0;
12
- z-index: 99999;
13
- display: flex;
14
- flex-direction: column;
15
- justify-content: center;
16
- align-items: center;
17
- background: rgba(255,255,255,0.92);
18
- min-height: 100vh;
19
- font-family: 'Inter', 'Segoe UI', Arial, sans-serif;
20
- }
21
- .devlinker-spinner-min {
22
- width: 38px;
23
- height: 38px;
24
- border: 4px solid #e0e7ef;
25
- border-top: 4px solid #2563eb;
26
- border-radius: 50%;
27
- animation: spin 1s linear infinite;
28
- margin-bottom: 2.5rem;
29
- }
30
- @keyframes spin {
31
- 100% { transform: rotate(360deg); }
32
- }
33
- .devlinker-powered {
34
- position: fixed;
35
- bottom: 18px;
36
- left: 0;
37
- width: 100vw;
38
- text-align: center;
39
- color: #2563eb;
40
- font-size: 1.08rem;
41
- font-weight: 500;
42
- opacity: 0.85;
43
- letter-spacing: 0.01em;
44
- pointer-events: none;
45
- user-select: none;
46
- z-index: 100000;
47
- }
48
- .devlinker-loading-msg {
49
- font-size: 1.18rem;
50
- color: #222;
51
- font-weight: 500;
52
- margin-top: -1.5rem;
53
- margin-bottom: 2.2rem;
54
- letter-spacing: 0.01em;
55
- text-align: center;
56
- opacity: 0.92;
57
- }
58
- .devlinker-3wev {
59
- font-size: 1.05rem;
60
- color: #2563eb;
61
- font-weight: 600;
62
- margin-top: 0.5rem;
63
- letter-spacing: 0.04em;
64
- text-align: center;
65
- opacity: 0.93;
66
- }
67
- </style>
68
- </head>
69
- <body>
70
- <div id="devlinker-loader-min">
71
- <div class="devlinker-spinner-min"></div>
72
- <div class="devlinker-loading-msg">Loading your app…</div>
73
- </div>
74
- <div class="devlinker-powered">Powered by DevLinker</div>
75
- <script>
76
- // Fetch the real page and replace the loader, but show loader for at least 1s
77
- (async function() {
78
- const minTime = 1000;
79
- const start = Date.now();
80
- try {
81
- const resp = await fetch(window.location.href, { headers: { 'X-DevLinker-Instant': '1' } });
82
- const html = await resp.text();
83
- const elapsed = Date.now() - start;
84
- const delay = Math.max(0, minTime - elapsed);
85
- setTimeout(() => {
86
- document.open();
87
- document.write(html);
88
- document.close();
89
- }, delay);
90
- } catch (e) {
91
- // Show error if fetch fails
92
- var loader = document.getElementById('devlinker-loader-min');
93
- if (loader) {
94
- loader.innerHTML += '<div style="color:#b91c1c;font-size:1.1rem;margin-top:1.5rem;">Failed to load app. Please check your connection.</div>';
95
- }
96
- }
97
- })();
98
- </script>
99
- </body>
100
- </html>
@@ -1,128 +0,0 @@
1
- <!-- DevLinker Next-Level Loader Overlay -->
2
- <style>
3
- #devlinker-loader {
4
- position: fixed;
5
- z-index: 99999;
6
- inset: 0;
7
- display: flex;
8
- align-items: center;
9
- justify-content: center;
10
- min-height: 100vh;
11
- background: linear-gradient(135deg, #f1f5f9 0%, #e0e7ff 100%);
12
- font-family: 'Inter', 'Segoe UI', Arial, sans-serif;
13
- transition: opacity 0.4s cubic-bezier(.4,0,.2,1);
14
- }
15
- .devlinker-loader-card {
16
- background: #fff;
17
- border-radius: 20px;
18
- box-shadow: 0 15px 40px rgba(0,0,0,0.08);
19
- padding: 40px 30px;
20
- min-width: 260px;
21
- max-width: 94vw;
22
- text-align: center;
23
- border: 1.5px solid #e0e7ef;
24
- position: relative;
25
- width: 100%;
26
- max-width: 350px;
27
- }
28
- .devlinker-logo {
29
- font-size: 2.1rem;
30
- font-weight: 600;
31
- color: #2563eb;
32
- margin-bottom: 20px;
33
- letter-spacing: -1.2px;
34
- user-select: none;
35
- }
36
- .devlinker-spin {
37
- margin: 20px auto;
38
- width: 40px;
39
- height: 40px;
40
- display: flex;
41
- align-items: center;
42
- justify-content: center;
43
- }
44
- .devlinker-spinner {
45
- width: 40px;
46
- height: 40px;
47
- border-radius: 50%;
48
- border: 4px solid #e0e7ef;
49
- border-top: 4px solid #3b82f6;
50
- animation: devlinker-spin 1.1s linear infinite;
51
- }
52
- @keyframes devlinker-spin {
53
- 100% { transform: rotate(360deg); }
54
- }
55
- .devlinker-brand {
56
- font-size: 1.13rem;
57
- color: #222;
58
- font-weight: 600;
59
- margin-bottom: 0.2rem;
60
- letter-spacing: -0.5px;
61
- }
62
- .devlinker-desc {
63
- font-size: 1.01rem;
64
- color: #555;
65
- margin-bottom: 0.7rem;
66
- }
67
- .devlinker-upgrade-btn {
68
- margin-top: 15px;
69
- background: #2563eb;
70
- color: #fff;
71
- border: none;
72
- padding: 8px 16px;
73
- border-radius: 8px;
74
- font-size: 14px;
75
- font-weight: 500;
76
- cursor: pointer;
77
- box-shadow: 0 2px 8px rgba(37,99,235,0.08);
78
- transition: background 0.2s, box-shadow 0.2s;
79
- outline: none;
80
- display: inline-block;
81
- }
82
- .devlinker-upgrade-btn:hover {
83
- background: #1d4ed8;
84
- box-shadow: 0 4px 16px rgba(37,99,235,0.13);
85
- }
86
- @media (max-width: 600px) {
87
- .devlinker-loader-card {
88
- min-width: 0;
89
- padding: 1.2rem 0.5rem 1.1rem 0.5rem;
90
- border-radius: 12px;
91
- }
92
- .devlinker-logo {
93
- font-size: 1.3rem;
94
- }
95
- .devlinker-upgrade-btn {
96
- font-size: 0.97rem;
97
- padding: 8px 12px;
98
- }
99
- }
100
- </style>
101
- <div id="devlinker-loader">
102
- <div class="devlinker-loader-card">
103
- <div class="devlinker-logo">∞ DevLinker</div>
104
- <div class="devlinker-spin"><div class="devlinker-spinner"></div></div>
105
- <div class="devlinker-brand">Loading your app...</div>
106
- <div class="devlinker-desc">Powered by <span style="color:#2563eb;font-weight:700;">DevLinker</span> 🚀</div>
107
- <a href="https://devlinker.app" target="_blank" rel="noopener">
108
- <button class="devlinker-upgrade-btn">Upgrade for custom domain</button>
109
- </a>
110
- </div>
111
- </div>
112
- <script>
113
- const start = Date.now();
114
- function hideLoader() {
115
- const minTime = 1000; // 1 sec branding (increased from 0.8s)
116
- const elapsed = Date.now() - start;
117
- const delay = Math.max(0, minTime - elapsed);
118
- setTimeout(() => {
119
- const el = document.getElementById("devlinker-loader");
120
- if (el) {
121
- el.style.opacity = "0";
122
- setTimeout(() => el.remove(), 400);
123
- }
124
- }, delay);
125
- }
126
- window.addEventListener("load", hideLoader);
127
- setTimeout(hideLoader, 7000); // max 7s (increased from 5s)
128
- </script>
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- devlinker = devlinker.main:cli
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes